diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..6c435f6 --- /dev/null +++ b/.env.dist @@ -0,0 +1,13 @@ +PAY_URL=https://bank.paysera.com/pay/ +PAYSERA_PAY_URL=https://bank.paysera.com/pay/ +XML_URL=https://www.paysera.com/new/api/paymentMethods/ + +PRODUCTION_PUBLIC_KEY=https://www.paysera.com/download/public.key +PRODUCTION_PAYMENT=https://bank.paysera.com/pay/ +PRODUCTION_PAYMENT_METHOD_LIST=https://www.paysera.com/new/api/paymentMethods/ +PRODUCTION_SMS_ANSWER=https://bank.paysera.com/psms/respond/ + +SANDBOX_PUBLIC_KEY=https://sandbox.paysera.com/download/public.key +SANDBOX_PAYMENT=https://sandbox.paysera.com/pay/ +SANDBOX_PAYMENT_METHOD_LIST=https://sandbox.paysera.com/new/api/paymentMethods/ +SANDBOX_SMS_ANSWER=https://sandbox.paysera.com/psms/respond/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1e43c5f..bc9adc2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ coverage .bash_history .php-cs-fixer.cache +/src/.env diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe14fa..0b80928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ Version history =============== +Version 3.1.0 - 2024-06-27 + + * added possibility to define Paysera routes in environment variables + Version 3.0.0 - 2024-04-30 * increased minimal PHP version to 7.4 diff --git a/README.md b/README.md index 247b65d..a31a9b7 100644 --- a/README.md +++ b/README.md @@ -52,4 +52,4 @@ PHP 7.4 standards and code style like `strict_types`, type hints etc Testing ======= - $ bash runt_tests.sh + $ bash run_tests.sh diff --git a/WebToPay.php b/WebToPay.php index c815e07..a32332e 100644 --- a/WebToPay.php +++ b/WebToPay.php @@ -38,20 +38,29 @@ class WebToPay /** * WebToPay Library version. */ - public const VERSION = '3.0.1'; + public const VERSION = '3.1.0'; /** * Server URL where all requests should go. + * + * @deprecated since 3.0.2 + * @see WebToPay_Config::getPayUrl */ public const PAY_URL = 'https://bank.paysera.com/pay/'; /** * Server URL where all non-lithuanian language requests should go. + * + * @deprecated since 3.0.2 + * @see WebToPay_Config::getPayseraPayUrl */ public const PAYSERA_PAY_URL = 'https://bank.paysera.com/pay/'; /** * Server URL where we can get XML with payment method data. + * + * @deprecated since 3.0.2 + * @see WebToPay_Config::getXmlUrl */ public const XML_URL = 'https://www.paysera.com/new/api/paymentMethods/'; @@ -86,7 +95,12 @@ public static function buildRequest(array $data): array unset($data['sign_password']); unset($data['projectid']); - $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); + $factory = new WebToPay_Factory( + [ + WebToPay_Config::PARAM_PROJECT_ID => (int)$projectId, + WebToPay_Config::PARAM_PASSWORD => $password, + ] + ); $requestBuilder = $factory->getRequestBuilder(); return $requestBuilder->buildRequest($data); @@ -98,8 +112,8 @@ public static function buildRequest(array $data): array * Possible array keys are described here: * https://developers.paysera.com/en/checkout/integrations/integration-specification * - * @param array $data Information about current payment request. - * @param boolean $exit if true, exits after sending Location header; default false + * @param array $data Information about current payment request. + * @param boolean $exit if true, exits after sending Location header; default false * * @throws WebToPayException on data validation error */ @@ -112,7 +126,12 @@ public static function redirectToPayment(array $data, bool $exit = false): void unset($data['sign_password']); unset($data['projectid']); - $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); + $factory = new WebToPay_Factory( + [ + WebToPay_Config::PARAM_PROJECT_ID => (int)$projectId, + WebToPay_Config::PARAM_PASSWORD => $password, + ] + ); $url = $factory->getRequestBuilder() ->buildRequestUrlFromData($data); @@ -143,7 +162,7 @@ public static function redirectToPayment(array $data, bool $exit = false): void * keys are described here: * https://developers.paysera.com/en/checkout/integrations/integration-specification * - * @param array $data Information about current payment request + * @param array $data Information about current payment request * * @return array * @@ -158,7 +177,12 @@ public static function buildRepeatRequest(array $data): array $projectId = $data['projectid']; $orderId = $data['orderid']; - $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); + $factory = new WebToPay_Factory( + [ + WebToPay_Config::PARAM_PROJECT_ID => (int)$projectId, + WebToPay_Config::PARAM_PASSWORD => $password, + ] + ); $requestBuilder = $factory->getRequestBuilder(); return $requestBuilder->buildRepeatRequest($orderId); @@ -172,9 +196,11 @@ public static function buildRepeatRequest(array $data): array */ public static function getPaymentUrl(string $language = 'LIT'): string { + $config = new WebToPay_Config(new WebToPay_EnvReader()); + return (in_array($language, ['lt', 'lit', 'LIT'], true)) - ? self::PAY_URL - : self::PAYSERA_PAY_URL; + ? $config->getPayUrl() + : $config->getPayseraPayUrl(); } /** @@ -192,7 +218,12 @@ public static function getPaymentUrl(string $language = 'LIT'): string */ public static function validateAndParseData(array $query, ?int $projectId, ?string $password): array { - $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); + $factory = new WebToPay_Factory( + [ + WebToPay_Config::PARAM_PROJECT_ID => $projectId, + WebToPay_Config::PARAM_PASSWORD => $password, + ] + ); $validator = $factory->getCallbackValidator(); return $validator->validateAndParseData($query); @@ -221,8 +252,7 @@ public static function smsAnswer(array $userData): void $logFile = $userData['log'] ?? null; try { - - $factory = new WebToPay_Factory(['password' => $password]); + $factory = new WebToPay_Factory([WebToPay_Config::PARAM_PASSWORD => $password]); $factory->getSmsAnswerSender()->sendAnswer($smsId, $text); if ($logFile) { @@ -248,7 +278,7 @@ public static function getPaymentMethodList( ?float $amount, ?string $currency = 'EUR' ): WebToPay_PaymentMethodList { - $factory = new WebToPay_Factory(['projectId' => $projectId]); + $factory = new WebToPay_Factory([WebToPay_Config::PARAM_PROJECT_ID => $projectId]); return $factory->getPaymentMethodListProvider()->getPaymentMethodList($amount, $currency); } @@ -274,13 +304,13 @@ protected static function log(string $type, string $msg, string $logfile): void $msg, ]; - $logline = implode(' ', $logline)."\n"; + $logline = implode(' ', $logline) . "\n"; fwrite($fp, $logline); fclose($fp); // clear big log file if (filesize($logfile) > 1024 * 1024 * pi()) { - copy($logfile, $logfile.'.old'); + copy($logfile, $logfile . '.old'); unlink($logfile); } } @@ -581,86 +611,183 @@ public function getBaseCurrency(): ?string /** - * Utility class + * Builds and signs requests */ -class WebToPay_Util +class WebToPay_RequestBuilder { - public const GCM_CIPHER = 'aes-256-gcm'; - public const GCM_AUTH_KEY_LENGTH = 16; + private const REQUEST_SPECS = [ + ['orderid', 40, true, ''], + ['accepturl', 255, true, ''], + ['cancelurl', 255, true, ''], + ['callbackurl', 255, true, ''], + ['lang', 3, false, '/^[a-z]{3}$/i'], + ['amount', 11, false, '/^\d+$/'], + ['currency', 3, false, '/^[a-z]{3}$/i'], + ['payment', 20, false, ''], + ['country', 2, false, '/^[a-z_]{2}$/i'], + ['paytext', 255, false, ''], + ['p_firstname', 255, false, ''], + ['p_lastname', 255, false, ''], + ['p_email', 255, false, ''], + ['p_street', 255, false, ''], + ['p_city', 255, false, ''], + ['p_state', 255, false, ''], + ['p_zip', 20, false, ''], + ['p_countrycode', 2, false, '/^[a-z]{2}$/i'], + ['test', 1, false, '/^[01]$/'], + ['time_limit', 19, false, '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'], + ]; + + protected string $projectPassword; + + protected WebToPay_Util $util; + + protected int $projectId; + + protected WebToPay_UrlBuilder $urlBuilder; /** - * Decodes url-safe-base64 encoded string - * Url-safe-base64 is same as base64, but + is replaced to - and / to _ + * Constructs object */ - public function decodeSafeUrlBase64(string $encodedText): string + public function __construct( + int $projectId, + string $projectPassword, + WebToPay_Util $util, + WebToPay_UrlBuilder $urlBuilder + ) { + $this->projectId = $projectId; + $this->projectPassword = $projectPassword; + $this->util = $util; + $this->urlBuilder = $urlBuilder; + } + + /** + * Builds request data array. + * + * This method checks all given data and generates correct request data + * array or raises WebToPayException on failure. + * + * @param array $data information about current payment request + * + * @return array + * + * @throws WebToPayException + */ + public function buildRequest(array $data): array { - return (string) base64_decode(strtr($encodedText, '-_', '+/'), true); + $this->validateRequest($data); + $data['version'] = WebToPay::VERSION; + $data['projectid'] = $this->projectId; + unset($data['repeat_request']); + + return $this->createRequest($data); } /** - * Encodes string to url-safe-base64 - * Url-safe-base64 is same as base64, but + is replaced to - and / to _ + * Builds the full request url (including the protocol and the domain) + * + * @param array $data + * @return string + * @throws WebToPayException */ - public function encodeSafeUrlBase64(string $text): string + public function buildRequestUrlFromData(array $data): string { - return strtr(base64_encode($text), '+/', '-_'); + $request = $this->buildRequest($data); + + return $this->urlBuilder->buildForRequest($request); } /** - * Decrypts string with aes-256-gcm algorithm + * Builds repeat request data array. + * + * This method checks all given data and generates correct request data + * array or raises WebToPayException on failure. + * + * @param int $orderId order id of repeated request + * + * @return array + * + * @throws WebToPayException */ - public function decryptGCM(string $stringToDecrypt, string $key): ?string + public function buildRepeatRequest(int $orderId): array { - $ivLength = (int) openssl_cipher_iv_length(self::GCM_CIPHER); - $iv = substr($stringToDecrypt, 0, $ivLength); - $ciphertext = substr($stringToDecrypt, $ivLength, -self::GCM_AUTH_KEY_LENGTH); - $tag = substr($stringToDecrypt, -self::GCM_AUTH_KEY_LENGTH); + $data['orderid'] = $orderId; + $data['version'] = WebToPay::VERSION; + $data['projectid'] = $this->projectId; + $data['repeat_request'] = '1'; - $decryptedText = openssl_decrypt( - $ciphertext, - self::GCM_CIPHER, - $key, - OPENSSL_RAW_DATA, - $iv, - $tag - ); + return $this->createRequest($data); + } - return $decryptedText === false ? null : $decryptedText; + /** + * Builds the full request url for a repeated request (including the protocol and the domain) + * + * @throws WebToPayException + */ + public function buildRepeatRequestUrlFromOrderId(int $orderId): string + { + $request = $this->buildRepeatRequest($orderId); + + return $this->urlBuilder->buildForRequest($request); } /** - * Parses HTTP query to array + * Checks data to be valid by passed specification * - * @param string $query + * @param array $data * - * @return array + * @throws WebToPay_Exception_Validation */ - public function parseHttpQuery(string $query): array + protected function validateRequest(array $data): void { - $params = []; - parse_str($query, $params); + foreach (self::REQUEST_SPECS as $spec) { + [$name, $maxlen, $required, $regexp] = $spec; - return $params; - } -} + if ($required && empty($data[$name])) { + throw new WebToPay_Exception_Validation( + sprintf("'%s' is required but missing.", $name), + WebToPayException::E_MISSING, + $name + ); + } + if (!empty($data[$name])) { + if (strlen((string) $data[$name]) > $maxlen) { + throw new WebToPay_Exception_Validation(sprintf( + "'%s' value is too long (%d), %d characters allowed.", + $name, + strlen((string) $data[$name]), + $maxlen + ), WebToPayException::E_MAXLEN, $name); + } -/** - * Raised on validation error in passed data when building the request - */ -class WebToPay_Exception_Validation extends WebToPayException -{ - public function __construct( - string $message, - int $code = 0, - ?string $field = null, - ?Exception $previousException = null - ) { - parent::__construct($message, $code, $previousException); - if ($field) { - $this->setField($field); + if ($regexp !== '' && !preg_match($regexp, (string) $data[$name])) { + throw new WebToPay_Exception_Validation( + sprintf("'%s' value '%s' is invalid.", $name, $data[$name]), + WebToPayException::E_REGEXP, + $name + ); + } + } } } + + /** + * Makes request data array from parameters, also generates signature + * + * @param array $request + * + * @return array + */ + protected function createRequest(array $request): array + { + $data = $this->util->encodeSafeUrlBase64(http_build_query($request, '', '&')); + + return [ + 'data' => $data, + 'sign' => md5($data . $this->projectPassword), + ]; + } } @@ -681,489 +808,850 @@ class WebToPay_Exception_Configuration extends WebToPayException /** - * The class is used for manipulating with behavior of functions in the global namespace. - * It is used for testing purposes. No payload. - * - * @codeCoverageIgnore + * Raised on validation error in passed data when building the request */ -class WebToPay_Functions +class WebToPay_Exception_Validation extends WebToPayException { - public static function function_exists(string $functionName): bool - { - return \function_exists($functionName); - } - - public static function headers_sent(): bool - { - return \headers_sent(); + public function __construct( + string $message, + int $code = 0, + ?string $field = null, + ?Exception $previousException = null + ) { + parent::__construct($message, $code, $previousException); + if ($field) { + $this->setField($field); + } } } /** - * Payment method configuration for some country + * Simple web client */ -class WebToPay_PaymentMethodCountry +class WebToPay_WebClient { - protected string $countryCode; - /** - * Holds available payment types for this country + * Gets page contents by specified URI. Adds query data if provided to the URI + * Ignores status code of the response and header fields * - * @var WebToPay_PaymentMethodGroup[] - */ - protected array $groups; - - /** - * Default language for titles - */ - protected string $defaultLanguage; - - /** - * Translations array for this country. Holds associative array of country title by language codes. + * @param string $uri + * @param array $queryData * - * @var array - */ - protected array $titleTranslations; + * @return string + * @throws WebToPayException + */ + public function get(string $uri, array $queryData = []): string + { + if (count($queryData) > 0) { + $uri .= strpos($uri, '?') === false ? '?' : '&'; + $uri .= http_build_query($queryData, '', '&'); + } + $url = parse_url($uri); + if ('https' === ($url['scheme'] ?? '')) { + $host = 'ssl://' . ($url['host'] ?? ''); + $port = 443; + } else { + $host = $url['host'] ?? ''; + $port = 80; + } + + $fp = $this->openSocket($host, $port, $errno, $errstr, 30); + if (!$fp) { + throw new WebToPayException(sprintf('Cannot connect to %s', $uri), WebToPayException::E_INVALID); + } + + if(isset($url['query'])) { + $data = ($url['path'] ?? '') . '?' . $url['query']; + } else { + $data = ($url['path'] ?? ''); + } + + $out = "GET " . $data . " HTTP/1.0\r\n"; + $out .= "Host: " . ($url['host'] ?? '') . "\r\n"; + $out .= "Connection: Close\r\n\r\n"; + + $content = $this->getContentFromSocket($fp, $out); + + // Separate header and content + [$header, $content] = explode("\r\n\r\n", $content, 2); + + return trim($content); + } + + /** + * @param string $host + * @param int $port + * @param int $errno + * @param string $errstr + * @param float $timeout + * @return false|resource + */ + protected function openSocket(string $host, int $port, &$errno, &$errstr, float $timeout = 30) + { + return fsockopen($host, $port, $errno, $errstr, $timeout); + } + + /** + * @param resource $fp + * @param string $out + * + * @return string + */ + protected function getContentFromSocket($fp, string $out): string + { + fwrite($fp, $out); + $content = (string) stream_get_contents($fp); + fclose($fp); + + return $content; + } +} + + +/** + * Utility class + */ +class WebToPay_Util +{ + public const GCM_CIPHER = 'aes-256-gcm'; + public const GCM_AUTH_KEY_LENGTH = 16; + + /** + * Decodes url-safe-base64 encoded string + * Url-safe-base64 is same as base64, but + is replaced to - and / to _ + */ + public function decodeSafeUrlBase64(string $encodedText): string + { + return (string) base64_decode(strtr($encodedText, '-_', '+/'), true); + } + + /** + * Encodes string to url-safe-base64 + * Url-safe-base64 is same as base64, but + is replaced to - and / to _ + */ + public function encodeSafeUrlBase64(string $text): string + { + return strtr(base64_encode($text), '+/', '-_'); + } + + /** + * Decrypts string with aes-256-gcm algorithm + */ + public function decryptGCM(string $stringToDecrypt, string $key): ?string + { + $ivLength = (int) openssl_cipher_iv_length(self::GCM_CIPHER); + $iv = substr($stringToDecrypt, 0, $ivLength); + $ciphertext = substr($stringToDecrypt, $ivLength, -self::GCM_AUTH_KEY_LENGTH); + $tag = substr($stringToDecrypt, -self::GCM_AUTH_KEY_LENGTH); + + $decryptedText = openssl_decrypt( + $ciphertext, + self::GCM_CIPHER, + $key, + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + return $decryptedText === false ? null : $decryptedText; + } + + /** + * Parses HTTP query to array + * + * @param string $query + * + * @return array + */ + public function parseHttpQuery(string $query): array + { + $params = []; + parse_str($query, $params); + + return $params; + } +} + + +/** + * Parses and validates callbacks + */ +class WebToPay_CallbackValidator +{ + protected WebToPay_Sign_SignCheckerInterface $signer; + + protected WebToPay_Util $util; + + protected int $projectId; + + protected ?string $password; /** * Constructs object * - * @param string $countryCode - * @param array $titleTranslations + * @param integer $projectId + * @param WebToPay_Sign_SignCheckerInterface $signer + * @param WebToPay_Util $util + * @param string|null $password + */ + public function __construct( + int $projectId, + WebToPay_Sign_SignCheckerInterface $signer, + WebToPay_Util $util, + ?string $password = null + ) { + $this->signer = $signer; + $this->util = $util; + $this->projectId = $projectId; + $this->password = $password; + } + + /** + * Parses callback parameters from query parameters and checks if sign is correct. + * Request has parameter "data", which is signed and holds all callback parameters + * + * @param array $requestData + * + * @return array Parsed callback parameters + * + * @throws WebToPayException + * @throws WebToPay_Exception_Callback + */ + public function validateAndParseData(array $requestData): array + { + if (!isset($requestData['data'])) { + throw new WebToPay_Exception_Callback('"data" parameter not found'); + } + + $data = $requestData['data']; + + if (isset($requestData['ss1']) || isset($requestData['ss2'])) { + if (!$this->signer->checkSign($requestData)) { + throw new WebToPay_Exception_Callback('Invalid sign parameters, check $_GET length limit'); + } + + $queryString = $this->util->decodeSafeUrlBase64($data); + } else { + if (null === $this->password) { + throw new WebToPay_Exception_Configuration('You have to provide project password'); + } + + $queryString = $this->util->decryptGCM( + $this->util->decodeSafeUrlBase64($data), + $this->password + ); + + if (null === $queryString) { + throw new WebToPay_Exception_Callback('Callback data decryption failed'); + } + } + $request = $this->util->parseHttpQuery($queryString); + + if (!isset($request['projectid'])) { + throw new WebToPay_Exception_Callback( + 'Project ID not provided in callback', + WebToPayException::E_INVALID + ); + } + + if ((string) $request['projectid'] !== (string) $this->projectId) { + throw new WebToPay_Exception_Callback( + sprintf('Bad projectid: %s, should be: %s', $request['projectid'], $this->projectId), + WebToPayException::E_INVALID + ); + } + + if (!isset($request['type']) || !in_array($request['type'], ['micro', 'macro'], true)) { + $micro = ( + isset($request['to']) + && isset($request['from']) + && isset($request['sms']) + ); + $request['type'] = $micro ? 'micro' : 'macro'; + } + + return $request; + } + + /** + * Checks data to have all the same parameters provided in expected array + * + * @param array $data + * @param array $expected + * + * @throws WebToPayException + */ + public function checkExpectedFields(array $data, array $expected): void + { + foreach ($expected as $key => $value) { + $passedValue = $data[$key] ?? null; + // there should be non-strict comparison here + if ($passedValue != $value) { + throw new WebToPayException( + sprintf('Field %s is not as expected (expected %s, got %s)', $key, $value, $passedValue) + ); + } + } + } +} + + +/** + * Class with all information about available payment methods for some project, optionally filtered by some amount. + */ +class WebToPay_PaymentMethodList +{ + /** + * Holds available payment countries + * + * @var WebToPay_PaymentMethodCountry[] + */ + protected array $countries; + + /** + * Default language for titles + */ + protected string $defaultLanguage; + + /** + * Project ID, to which this method list is valid + */ + protected int $projectId; + + /** + * Currency for min and max amounts in this list + */ + protected string $currency; + + /** + * If this list is filtered for some amount, this field defines it + */ + protected ?int $amount; + + /** + * Constructs object + * + * @param int $projectId + * @param string $currency currency for min and max amounts in this list * @param string $defaultLanguage + * @param int|null $amount null if this list is not filtered by amount */ - public function __construct(string $countryCode, array $titleTranslations, string $defaultLanguage = 'lt') + public function __construct(int $projectId, string $currency, string $defaultLanguage = 'lt', ?int $amount = null) { - $this->countryCode = $countryCode; + $this->projectId = $projectId; + $this->countries = []; $this->defaultLanguage = $defaultLanguage; - $this->titleTranslations = $titleTranslations; - $this->groups = []; + $this->currency = $currency; + $this->amount = $amount; } /** * Sets default language for titles. * Returns itself for fluent interface */ - public function setDefaultLanguage(string $language): WebToPay_PaymentMethodCountry + public function setDefaultLanguage(string $language): WebToPay_PaymentMethodList { $this->defaultLanguage = $language; - foreach ($this->groups as $group) { - $group->setDefaultLanguage($language); + foreach ($this->countries as $country) { + $country->setDefaultLanguage($language); } return $this; } /** - * Gets title of the group. Tries to get title in specified language. If it is not found or if language is not - * specified, uses default language, given to constructor. + * Gets default language for titles */ - public function getTitle(?string $languageCode = null): string + public function getDefaultLanguage(): string { - if ($languageCode !== null && isset($this->titleTranslations[$languageCode])) { - return $this->titleTranslations[$languageCode]; - } elseif (isset($this->titleTranslations[$this->defaultLanguage])) { - return $this->titleTranslations[$this->defaultLanguage]; + return $this->defaultLanguage; + } + + /** + * Gets project ID for this payment method list + */ + public function getProjectId(): int + { + return $this->projectId; + } + + /** + * Gets currency for min and max amounts in this list + */ + public function getCurrency(): string + { + return $this->currency; + } + + /** + * Gets whether this list is already filtered for some amount + */ + public function isFiltered(): bool + { + return $this->amount !== null; + } + + /** + * Returns available countries + * + * @return WebToPay_PaymentMethodCountry[] + */ + public function getCountries(): array + { + return $this->countries; + } + + /** + * Adds new country to payment methods. If some other country with same code was registered earlier, overwrites it. + * Returns added country instance + */ + public function addCountry(WebToPay_PaymentMethodCountry $country): WebToPay_PaymentMethodCountry + { + return $this->countries[$country->getCode()] = $country; + } + + /** + * Gets country object with specified country code. If no country with such country code is found, returns null. + */ + public function getCountry(string $countryCode): ?WebToPay_PaymentMethodCountry + { + return $this->countries[$countryCode] ?? null; + } + + /** + * Returns new payment method list instance with only those payment methods, which are available for provided + * amount. + * Returns itself, if list is already filtered and filter amount matches the given one. + * + * @throws WebToPayException if this list is already filtered and not for provided amount + */ + public function filterForAmount(int $amount, string $currency): WebToPay_PaymentMethodList + { + if ($currency !== $this->currency) { + throw new WebToPayException( + 'Currencies do not match. Given currency: ' + . $currency + . ', currency in list: ' + . $this->currency + ); + } + if ($this->isFiltered()) { + if ($this->amount === $amount) { + return $this; + } else { + throw new WebToPayException('This list is already filtered, use unfiltered list instead'); + } } else { - return $this->countryCode; + $list = new WebToPay_PaymentMethodList($this->projectId, $currency, $this->defaultLanguage, $amount); + foreach ($this->getCountries() as $country) { + $country = $country->filterForAmount($amount, $currency); + if (!$country->isEmpty()) { + $list->addCountry($country); + } + } + + return $list; } } /** - * Gets default language for titles + * Loads countries from given XML node */ - public function getDefaultLanguage(): string + public function fromXmlNode(SimpleXMLElement $xmlNode): void { - return $this->defaultLanguage; + foreach ($xmlNode->country as $countryNode) { + $titleTranslations = []; + foreach ($countryNode->title as $titleNode) { + $titleTranslations[(string)$titleNode->attributes()->language] = (string)$titleNode; + } + $this->addCountry($this->createCountry((string)$countryNode->attributes()->code, $titleTranslations)) + ->fromXmlNode($countryNode); + } } /** - * Gets country code + * Method to create new country instances. Overwrite if you have to use some other country subtype. + * + * @param string $countryCode + * @param array $titleTranslations + * + * @return WebToPay_PaymentMethodCountry */ - public function getCode(): string + protected function createCountry(string $countryCode, array $titleTranslations = []): WebToPay_PaymentMethodCountry { - return $this->countryCode; + return new WebToPay_PaymentMethodCountry($countryCode, $titleTranslations, $this->defaultLanguage); } +} + + +/** + * Used to build a complete request URL. + * + * Class WebToPay_UrlBuilder + */ +class WebToPay_UrlBuilder +{ + public const PLACEHOLDER_KEY = '[domain]'; + + protected WebToPay_Config $configuration; + + protected string $environment; /** - * Adds new group to payment methods for this country. - * If some other group was registered earlier with same key, overwrites it. - * Returns given group + * @var array */ - public function addGroup(WebToPay_PaymentMethodGroup $group): WebToPay_PaymentMethodGroup - { - return $this->groups[$group->getKey()] = $group; - } + protected WebToPay_Routes $routes; /** - * Gets group object with specified group key. If no group with such key is found, returns null. + * @param WebToPay_Config $configuration + * @param string $environment */ - public function getGroup(string $groupKey): ?WebToPay_PaymentMethodGroup + public function __construct(WebToPay_Config $configuration, string $environment) { - return $this->groups[$groupKey] ?? null; + $this->configuration = $configuration; + $this->environment = $environment; + $this->routes = $this->configuration->getRoutes(); } - /** - * Returns payment method groups registered for this country. - * - * @return WebToPay_PaymentMethodGroup[] - */ - public function getGroups(): array + public function getEnvironment(): string { - return $this->groups; + return $this->environment; } /** - * Gets payment methods in all groups + * Builds a complete request URL based on the provided parameters * - * @return WebToPay_PaymentMethod[] + * @param array $request + * + * @return string */ - public function getPaymentMethods(): array + public function buildForRequest(array $request): string { - $paymentMethods = []; - foreach ($this->groups as $group) { - $paymentMethods = array_merge($paymentMethods, $group->getPaymentMethods()); - } - - return $paymentMethods; + return $this->createUrlFromRequestAndLanguage($request); } /** - * Returns new country instance with only those payment methods, which are available for provided amount. + * Builds a complete URL for payment list API */ - public function filterForAmount(int $amount, string $currency): WebToPay_PaymentMethodCountry + public function buildForPaymentsMethodList(int $projectId, ?string $amount, ?string $currency): string { - $country = new WebToPay_PaymentMethodCountry($this->countryCode, $this->titleTranslations, $this->defaultLanguage); - foreach ($this->getGroups() as $group) { - $group = $group->filterForAmount($amount, $currency); - if (!$group->isEmpty()) { - $country->addGroup($group); - } - } + $route = $this->routes->getPaymentMethodListRoute(); - return $country; + return $route . $projectId . '/currency:' . $currency . '/amount:' . $amount; } /** - * Returns new country instance with only those payment methods, which are returns or not iban number after payment + * Builds a complete URL for Sms Answer + * + * @codeCoverageIgnore */ - public function filterForIban(bool $isIban = true): WebToPay_PaymentMethodCountry + public function buildForSmsAnswer(): string { - $country = new WebToPay_PaymentMethodCountry( - $this->countryCode, - $this->titleTranslations, - $this->defaultLanguage - ); - - foreach ($this->getGroups() as $group) { - $group = $group->filterForIban($isIban); - if (!$group->isEmpty()) { - $country->addGroup($group); - } - } - - return $country; + return $this->routes->getSmsAnswerRoute(); } /** - * Returns whether this country has no groups + * Build the URL to the public key */ - public function isEmpty(): bool + public function buildForPublicKey(): string { - return count($this->groups) === 0; + return $this->routes->getPublicKeyRoute(); } /** - * Loads groups from given XML node + * Creates a URL from the request and data provided. + * + * @param array $request + * + * @return string */ - public function fromXmlNode(SimpleXMLElement $countryNode): void + protected function createUrlFromRequestAndLanguage(array $request): string { - foreach ($countryNode->payment_group as $groupNode) { - $key = (string) $groupNode->attributes()->key; - $titleTranslations = []; - foreach ($groupNode->title as $titleNode) { - $titleTranslations[(string) $titleNode->attributes()->language] = (string) $titleNode; - } - $this->addGroup($this->createGroup($key, $titleTranslations))->fromXmlNode($groupNode); - } + $url = $this->getPaymentUrl() . '?' . http_build_query($request, '', '&'); + + return preg_replace('/[\r\n]+/is', '', $url) ?? ''; } /** - * Method to create new group instances. Overwrite if you have to use some other group subtype. - * - * @param string $groupKey - * @param array $translations + * Returns payment URL. Argument is same as lang parameter in request data * - * @return WebToPay_PaymentMethodGroup + * @return string */ - protected function createGroup(string $groupKey, array $translations = []): WebToPay_PaymentMethodGroup + public function getPaymentUrl(): string { - return new WebToPay_PaymentMethodGroup($groupKey, $translations, $this->defaultLanguage); + return $this->routes->getPaymentRoute(); } } /** - * Builds and signs requests + * Creates objects. Also caches to avoid creating several instances of same objects */ -class WebToPay_RequestBuilder +class WebToPay_Factory { - private const REQUEST_SPECS = [ - ['orderid', 40, true, ''], - ['accepturl', 255, true, ''], - ['cancelurl', 255, true, ''], - ['callbackurl', 255, true, ''], - ['lang', 3, false, '/^[a-z]{3}$/i'], - ['amount', 11, false, '/^\d+$/'], - ['currency', 3, false, '/^[a-z]{3}$/i'], - ['payment', 20, false, ''], - ['country', 2, false, '/^[a-z_]{2}$/i'], - ['paytext', 255, false, ''], - ['p_firstname', 255, false, ''], - ['p_lastname', 255, false, ''], - ['p_email', 255, false, ''], - ['p_street', 255, false, ''], - ['p_city', 255, false, ''], - ['p_state', 255, false, ''], - ['p_zip', 20, false, ''], - ['p_countrycode', 2, false, '/^[a-z]{2}$/i'], - ['test', 1, false, '/^[01]$/'], - ['time_limit', 19, false, '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'], + /** + * @deprecated since 3.0.2 + */ + public const ENV_PRODUCTION = 'production'; + /** + * @deprecated since 3.0.2 + */ + public const ENV_SANDBOX = 'sandbox'; + + /** + * @var array + * + * @deprecated since 3.0.2 + */ + protected static array $defaultConfiguration = [ + 'routes' => [ + self::ENV_PRODUCTION => [ + 'publicKey' => 'https://www.paysera.com/download/public.key', + 'payment' => 'https://bank.paysera.com/pay/', + 'paymentMethodList' => 'https://www.paysera.com/new/api/paymentMethods/', + 'smsAnswer' => 'https://bank.paysera.com/psms/respond/', + ], + self::ENV_SANDBOX => [ + 'publicKey' => 'https://sandbox.paysera.com/download/public.key', + 'payment' => 'https://sandbox.paysera.com/pay/', + 'paymentMethodList' => 'https://sandbox.paysera.com/new/api/paymentMethods/', + 'smsAnswer' => 'https://sandbox.paysera.com/psms/respond/', + ], + ], ]; - protected string $projectPassword; + protected string $environment; - protected WebToPay_Util $util; + protected WebToPay_Config $configuration; - protected int $projectId; + protected ?WebToPay_WebClient $webClient = null; - protected WebToPay_UrlBuilder $urlBuilder; + protected ?WebToPay_CallbackValidator $callbackValidator = null; + + protected ?WebToPay_RequestBuilder $requestBuilder = null; + + protected ?WebToPay_Sign_SignCheckerInterface $signer = null; + + protected ?WebToPay_SmsAnswerSender $smsAnswerSender = null; + + protected ?WebToPay_PaymentMethodListProvider $paymentMethodListProvider = null; + + protected ?WebToPay_Util $util = null; + + protected ?WebToPay_UrlBuilder $urlBuilder = null; /** - * Constructs object + * Constructs object. + * Configuration keys: projectId, password + * They are required only when some object being created needs them, + * if they are not found at that moment - exception is thrown + * + * @param array $configuration */ - public function __construct( - int $projectId, - string $projectPassword, - WebToPay_Util $util, - WebToPay_UrlBuilder $urlBuilder - ) { - $this->projectId = $projectId; - $this->projectPassword = $projectPassword; - $this->util = $util; - $this->urlBuilder = $urlBuilder; + public function __construct(array $configuration = []) + { + $this->environment = WebToPay_Config::PRODUCTION; + $this->configuration = new WebToPay_Config( + new WebToPay_EnvReader(), + $this->environment, + $configuration + ); } /** - * Builds request data array. - * - * This method checks all given data and generates correct request data - * array or raises WebToPayException on failure. - * - * @param array $data information about current payment request - * - * @return array + * If passed true the factory will use sandbox when constructing URLs + */ + public function useSandbox(bool $enableSandbox): self + { + if ($enableSandbox) { + $this->environment = WebToPay_Config::SANDBOX; + } else { + $this->environment = WebToPay_Config::PRODUCTION; + } + + $this->configuration->switchEnvironment($this->environment); + + return $this; + } + + /** + * Creates or gets callback validator instance * * @throws WebToPayException + * @throws WebToPay_Exception_Configuration */ - public function buildRequest(array $data): array + public function getCallbackValidator(): WebToPay_CallbackValidator { - $this->validateRequest($data); - $data['version'] = WebToPay::VERSION; - $data['projectid'] = $this->projectId; - unset($data['repeat_request']); + if ($this->callbackValidator === null) { + if ($this->configuration->getProjectId() === null) { + throw new WebToPay_Exception_Configuration('You have to provide project ID'); + } - return $this->createRequest($data); + $this->callbackValidator = new WebToPay_CallbackValidator( + $this->configuration->getProjectId(), + $this->getSigner(), + $this->getUtil(), + $this->configuration->getPassword() + ); + } + + return $this->callbackValidator; } /** - * Builds the full request url (including the protocol and the domain) + * Creates or gets request builder instance * - * @param array $data - * @return string - * @throws WebToPayException + * @throws WebToPay_Exception_Configuration */ - public function buildRequestUrlFromData(array $data): string + public function getRequestBuilder(): WebToPay_RequestBuilder { - $request = $this->buildRequest($data); + if ($this->requestBuilder === null) { + if ($this->configuration->getPassword() === null) { + throw new WebToPay_Exception_Configuration('You have to provide project password to sign request'); + } + if ($this->configuration->getProjectId() === null) { + throw new WebToPay_Exception_Configuration('You have to provide project ID'); + } + $this->requestBuilder = new WebToPay_RequestBuilder( + $this->configuration->getProjectId(), + $this->configuration->getPassword(), + $this->getUtil(), + $this->getUrlBuilder() + ); + } + + return $this->requestBuilder; + } + + public function getUrlBuilder(): WebToPay_UrlBuilder + { + if ($this->urlBuilder === null || $this->urlBuilder->getEnvironment() !== $this->environment) { + $this->urlBuilder = new WebToPay_UrlBuilder( + $this->configuration, + $this->environment + ); + } - return $this->urlBuilder->buildForRequest($request); + return $this->urlBuilder; } /** - * Builds repeat request data array. - * - * This method checks all given data and generates correct request data - * array or raises WebToPayException on failure. - * - * @param int $orderId order id of repeated request - * - * @return array + * Creates or gets SMS answer sender instance * - * @throws WebToPayException + * @throws WebToPay_Exception_Configuration */ - public function buildRepeatRequest(int $orderId): array + public function getSmsAnswerSender(): WebToPay_SmsAnswerSender { - $data['orderid'] = $orderId; - $data['version'] = WebToPay::VERSION; - $data['projectid'] = $this->projectId; - $data['repeat_request'] = '1'; + if ($this->smsAnswerSender === null) { + if ($this->configuration->getPassword() === null) { + throw new WebToPay_Exception_Configuration('You have to provide project password'); + } + $this->smsAnswerSender = new WebToPay_SmsAnswerSender( + $this->configuration->getPassword(), + $this->getWebClient(), + $this->getUrlBuilder() + ); + } - return $this->createRequest($data); + return $this->smsAnswerSender; } /** - * Builds the full request url for a repeated request (including the protocol and the domain) + * Creates or gets payment list provider instance * * @throws WebToPayException + * @throws WebToPay_Exception_Configuration */ - public function buildRepeatRequestUrlFromOrderId(int $orderId): string + public function getPaymentMethodListProvider(): WebToPay_PaymentMethodListProvider { - $request = $this->buildRepeatRequest($orderId); + if ($this->paymentMethodListProvider === null) { + if ($this->configuration->getProjectId() === null) { + throw new WebToPay_Exception_Configuration('You have to provide project ID'); + } + $this->paymentMethodListProvider = new WebToPay_PaymentMethodListProvider( + $this->configuration->getProjectId(), + $this->getWebClient(), + $this->getUrlBuilder() + ); + } - return $this->urlBuilder->buildForRequest($request); + return $this->paymentMethodListProvider; } /** - * Checks data to be valid by passed specification - * - * @param array $data + * Creates or gets signer instance. Chooses SS2 signer if openssl functions are available, SS1 in other case * - * @throws WebToPay_Exception_Validation + * @throws WebToPay_Exception_Configuration + * @throws WebToPayException */ - protected function validateRequest(array $data): void + protected function getSigner(): WebToPay_Sign_SignCheckerInterface { - foreach (self::REQUEST_SPECS as $spec) { - [$name, $maxlen, $required, $regexp] = $spec; - - if ($required && empty($data[$name])) { - throw new WebToPay_Exception_Validation( - sprintf("'%s' is required but missing.", $name), - WebToPayException::E_MISSING, - $name - ); - } - - if (!empty($data[$name])) { - if (strlen((string) $data[$name]) > $maxlen) { - throw new WebToPay_Exception_Validation(sprintf( - "'%s' value is too long (%d), %d characters allowed.", - $name, - strlen((string) $data[$name]), - $maxlen - ), WebToPayException::E_MAXLEN, $name); + if ($this->signer === null) { + if (WebToPay_Functions::function_exists('openssl_pkey_get_public')) { + $webClient = $this->getWebClient(); + $publicKey = $webClient->get($this->getUrlBuilder()->buildForPublicKey()); + if (!$publicKey) { + throw new WebToPayException('Cannot download public key from WebToPay website'); } - - if ($regexp !== '' && !preg_match($regexp, (string) $data[$name])) { - throw new WebToPay_Exception_Validation( - sprintf("'%s' value '%s' is invalid.", $name, $data[$name]), - WebToPayException::E_REGEXP, - $name + $this->signer = new WebToPay_Sign_SS2SignChecker($publicKey, $this->getUtil()); + } else { + if ($this->configuration->getPassword() === null) { + throw new WebToPay_Exception_Configuration( + 'You have to provide project password if OpenSSL is unavailable' ); } + $this->signer = new WebToPay_Sign_SS1SignChecker($this->configuration->getPassword()); } } + + return $this->signer; } /** - * Makes request data array from parameters, also generates signature - * - * @param array $request - * - * @return array + * Creates or gets web client instance */ - protected function createRequest(array $request): array + protected function getWebClient(): WebToPay_WebClient { - $data = $this->util->encodeSafeUrlBase64(http_build_query($request, '', '&')); + if ($this->webClient === null) { + $this->webClient = new WebToPay_WebClient(); + } - return [ - 'data' => $data, - 'sign' => md5($data . $this->projectPassword), - ]; + return $this->webClient; } -} - -/** - * Simple web client - */ -class WebToPay_WebClient -{ /** - * Gets page contents by specified URI. Adds query data if provided to the URI - * Ignores status code of the response and header fields - * - * @param string $uri - * @param array $queryData + * Creates or gets util instance * - * @return string - * @throws WebToPayException + * @throws WebToPay_Exception_Configuration */ - public function get(string $uri, array $queryData = []): string + protected function getUtil(): WebToPay_Util { - if (count($queryData) > 0) { - $uri .= strpos($uri, '?') === false ? '?' : '&'; - $uri .= http_build_query($queryData, '', '&'); - } - $url = parse_url($uri); - if ('https' === ($url['scheme'] ?? '')) { - $host = 'ssl://' . ($url['host'] ?? ''); - $port = 443; - } else { - $host = $url['host'] ?? ''; - $port = 80; - } - - $fp = $this->openSocket($host, $port, $errno, $errstr, 30); - if (!$fp) { - throw new WebToPayException(sprintf('Cannot connect to %s', $uri), WebToPayException::E_INVALID); - } - - if(isset($url['query'])) { - $data = ($url['path'] ?? '') . '?' . $url['query']; - } else { - $data = ($url['path'] ?? ''); + if ($this->util === null) { + $this->util = new WebToPay_Util(); } - $out = "GET " . $data . " HTTP/1.0\r\n"; - $out .= "Host: " . ($url['host'] ?? '') . "\r\n"; - $out .= "Connection: Close\r\n\r\n"; - - $content = $this->getContentFromSocket($fp, $out); - - // Separate header and content - [$header, $content] = explode("\r\n\r\n", $content, 2); - - return trim($content); + return $this->util; } +} - /** - * @param string $host - * @param int $port - * @param int $errno - * @param string $errstr - * @param float $timeout - * @return false|resource - */ - protected function openSocket(string $host, int $port, &$errno, &$errstr, float $timeout = 30) + +/** + * The class is used for manipulating with behavior of functions in the global namespace. + * It is used for testing purposes. No payload. + * + * @codeCoverageIgnore + */ +class WebToPay_Functions +{ + public static function function_exists(string $functionName): bool { - return fsockopen($host, $port, $errno, $errstr, $timeout); + return \function_exists($functionName); } - /** - * @param resource $fp - * @param string $out - * - * @return string - */ - protected function getContentFromSocket($fp, string $out): string + public static function headers_sent(): bool { - fwrite($fp, $out); - $content = (string) stream_get_contents($fp); - fclose($fp); - - return $content; + return \headers_sent(); } } @@ -1216,16 +1704,18 @@ public function sendAnswer(int $smsId, string $text): void /** - * Class with all information about available payment methods for some project, optionally filtered by some amount. + * Payment method configuration for some country */ -class WebToPay_PaymentMethodList +class WebToPay_PaymentMethodCountry { + protected string $countryCode; + /** - * Holds available payment countries + * Holds available payment types for this country * - * @var WebToPay_PaymentMethodCountry[] + * @var WebToPay_PaymentMethodGroup[] */ - protected array $countries; + protected array $groups; /** * Default language for titles @@ -1233,676 +1723,611 @@ class WebToPay_PaymentMethodList protected string $defaultLanguage; /** - * Project ID, to which this method list is valid - */ - protected int $projectId; - - /** - * Currency for min and max amounts in this list - */ - protected string $currency; - - /** - * If this list is filtered for some amount, this field defines it + * Translations array for this country. Holds associative array of country title by language codes. + * + * @var array */ - protected ?int $amount; + protected array $titleTranslations; /** * Constructs object * - * @param int $projectId - * @param string $currency currency for min and max amounts in this list + * @param string $countryCode + * @param array $titleTranslations * @param string $defaultLanguage - * @param int|null $amount null if this list is not filtered by amount */ - public function __construct(int $projectId, string $currency, string $defaultLanguage = 'lt', ?int $amount = null) + public function __construct(string $countryCode, array $titleTranslations, string $defaultLanguage = 'lt') { - $this->projectId = $projectId; - $this->countries = []; + $this->countryCode = $countryCode; $this->defaultLanguage = $defaultLanguage; - $this->currency = $currency; - $this->amount = $amount; + $this->titleTranslations = $titleTranslations; + $this->groups = []; } /** * Sets default language for titles. * Returns itself for fluent interface */ - public function setDefaultLanguage(string $language): WebToPay_PaymentMethodList + public function setDefaultLanguage(string $language): WebToPay_PaymentMethodCountry { $this->defaultLanguage = $language; - foreach ($this->countries as $country) { - $country->setDefaultLanguage($language); - } - - return $this; - } - - /** - * Gets default language for titles - */ - public function getDefaultLanguage(): string - { - return $this->defaultLanguage; + foreach ($this->groups as $group) { + $group->setDefaultLanguage($language); + } + + return $this; } /** - * Gets project ID for this payment method list + * Gets title of the group. Tries to get title in specified language. If it is not found or if language is not + * specified, uses default language, given to constructor. */ - public function getProjectId(): int + public function getTitle(?string $languageCode = null): string { - return $this->projectId; + if ($languageCode !== null && isset($this->titleTranslations[$languageCode])) { + return $this->titleTranslations[$languageCode]; + } elseif (isset($this->titleTranslations[$this->defaultLanguage])) { + return $this->titleTranslations[$this->defaultLanguage]; + } else { + return $this->countryCode; + } } /** - * Gets currency for min and max amounts in this list + * Gets default language for titles */ - public function getCurrency(): string + public function getDefaultLanguage(): string { - return $this->currency; + return $this->defaultLanguage; } /** - * Gets whether this list is already filtered for some amount + * Gets country code */ - public function isFiltered(): bool + public function getCode(): string { - return $this->amount !== null; + return $this->countryCode; } /** - * Returns available countries - * - * @return WebToPay_PaymentMethodCountry[] + * Adds new group to payment methods for this country. + * If some other group was registered earlier with same key, overwrites it. + * Returns given group */ - public function getCountries(): array + public function addGroup(WebToPay_PaymentMethodGroup $group): WebToPay_PaymentMethodGroup { - return $this->countries; + return $this->groups[$group->getKey()] = $group; } /** - * Adds new country to payment methods. If some other country with same code was registered earlier, overwrites it. - * Returns added country instance + * Gets group object with specified group key. If no group with such key is found, returns null. */ - public function addCountry(WebToPay_PaymentMethodCountry $country): WebToPay_PaymentMethodCountry + public function getGroup(string $groupKey): ?WebToPay_PaymentMethodGroup { - return $this->countries[$country->getCode()] = $country; + return $this->groups[$groupKey] ?? null; } /** - * Gets country object with specified country code. If no country with such country code is found, returns null. + * Returns payment method groups registered for this country. + * + * @return WebToPay_PaymentMethodGroup[] */ - public function getCountry(string $countryCode): ?WebToPay_PaymentMethodCountry + public function getGroups(): array { - return $this->countries[$countryCode] ?? null; + return $this->groups; } /** - * Returns new payment method list instance with only those payment methods, which are available for provided - * amount. - * Returns itself, if list is already filtered and filter amount matches the given one. + * Gets payment methods in all groups * - * @throws WebToPayException if this list is already filtered and not for provided amount + * @return WebToPay_PaymentMethod[] */ - public function filterForAmount(int $amount, string $currency): WebToPay_PaymentMethodList + public function getPaymentMethods(): array { - if ($currency !== $this->currency) { - throw new WebToPayException( - 'Currencies do not match. Given currency: ' - . $currency - . ', currency in list: ' - . $this->currency - ); + $paymentMethods = []; + foreach ($this->groups as $group) { + $paymentMethods = array_merge($paymentMethods, $group->getPaymentMethods()); } - if ($this->isFiltered()) { - if ($this->amount === $amount) { - return $this; - } else { - throw new WebToPayException('This list is already filtered, use unfiltered list instead'); - } - } else { - $list = new WebToPay_PaymentMethodList($this->projectId, $currency, $this->defaultLanguage, $amount); - foreach ($this->getCountries() as $country) { - $country = $country->filterForAmount($amount, $currency); - if (!$country->isEmpty()) { - $list->addCountry($country); - } - } - return $list; - } + return $paymentMethods; } /** - * Loads countries from given XML node + * Returns new country instance with only those payment methods, which are available for provided amount. */ - public function fromXmlNode(SimpleXMLElement $xmlNode): void + public function filterForAmount(int $amount, string $currency): WebToPay_PaymentMethodCountry { - foreach ($xmlNode->country as $countryNode) { - $titleTranslations = []; - foreach ($countryNode->title as $titleNode) { - $titleTranslations[(string)$titleNode->attributes()->language] = (string)$titleNode; + $country = new WebToPay_PaymentMethodCountry($this->countryCode, $this->titleTranslations, $this->defaultLanguage); + foreach ($this->getGroups() as $group) { + $group = $group->filterForAmount($amount, $currency); + if (!$group->isEmpty()) { + $country->addGroup($group); } - $this->addCountry($this->createCountry((string)$countryNode->attributes()->code, $titleTranslations)) - ->fromXmlNode($countryNode); } - } - /** - * Method to create new country instances. Overwrite if you have to use some other country subtype. - * - * @param string $countryCode - * @param array $titleTranslations - * - * @return WebToPay_PaymentMethodCountry - */ - protected function createCountry(string $countryCode, array $titleTranslations = []): WebToPay_PaymentMethodCountry - { - return new WebToPay_PaymentMethodCountry($countryCode, $titleTranslations, $this->defaultLanguage); + return $country; } -} - - -/** - * Sign checker which checks SS1 signature. SS1 does not depend on SSL functions - */ -class WebToPay_Sign_SS1SignChecker implements WebToPay_Sign_SignCheckerInterface -{ - protected string $projectPassword; /** - * Constructs object + * Returns new country instance with only those payment methods, which are returns or not iban number after payment */ - public function __construct(string $projectPassword) + public function filterForIban(bool $isIban = true): WebToPay_PaymentMethodCountry { - $this->projectPassword = $projectPassword; - } + $country = new WebToPay_PaymentMethodCountry( + $this->countryCode, + $this->titleTranslations, + $this->defaultLanguage + ); - /** - * Check for SS1, which is not depend on openssl functions. - * - * @param array $request - * - * @return bool - * - * @throws WebToPay_Exception_Callback - */ - public function checkSign(array $request): bool - { - if (!isset($request['data']) || !isset($request['ss1'])) { - throw new WebToPay_Exception_Callback('Not enough parameters in callback. Possible version mismatch'); + foreach ($this->getGroups() as $group) { + $group = $group->filterForIban($isIban); + if (!$group->isEmpty()) { + $country->addGroup($group); + } } - return md5($request['data'] . $this->projectPassword) === $request['ss1']; + return $country; } -} - - -/** - * Checks SS2 signature. Depends on SSL functions - */ -class WebToPay_Sign_SS2SignChecker implements WebToPay_Sign_SignCheckerInterface -{ - protected string $publicKey; - - protected WebToPay_Util $util; /** - * Constructs object + * Returns whether this country has no groups */ - public function __construct(string $publicKey, WebToPay_Util $util) + public function isEmpty(): bool { - $this->publicKey = $publicKey; - $this->util = $util; + return count($this->groups) === 0; } /** - * Checks signature - * - * @param array $request - * - * @return bool - * - * @throws WebToPay_Exception_Callback + * Loads groups from given XML node */ - public function checkSign(array $request): bool + public function fromXmlNode(SimpleXMLElement $countryNode): void { - if (!isset($request['data']) || !isset($request['ss2'])) { - throw new WebToPay_Exception_Callback('Not enough parameters in callback. Possible version mismatch'); + foreach ($countryNode->payment_group as $groupNode) { + $key = (string) $groupNode->attributes()->key; + $titleTranslations = []; + foreach ($groupNode->title as $titleNode) { + $titleTranslations[(string) $titleNode->attributes()->language] = (string) $titleNode; + } + $this->addGroup($this->createGroup($key, $titleTranslations))->fromXmlNode($groupNode); } - - $ss2 = $this->util->decodeSafeUrlBase64($request['ss2']); - $ok = openssl_verify($request['data'], $ss2, $this->publicKey); - - return $ok === 1; } -} - -/** - * Interface for sign checker - */ -interface WebToPay_Sign_SignCheckerInterface -{ /** - * Checks whether request is signed properly + * Method to create new group instances. Overwrite if you have to use some other group subtype. * - * @param array $request + * @param string $groupKey + * @param array $translations * - * @return boolean + * @return WebToPay_PaymentMethodGroup */ - public function checkSign(array $request): bool; + protected function createGroup(string $groupKey, array $translations = []): WebToPay_PaymentMethodGroup + { + return new WebToPay_PaymentMethodGroup($groupKey, $translations, $this->defaultLanguage); + } } /** - * Loads data about payment methods and constructs payment method list object from that data - * You need SimpleXML support to use this feature + * A helper tool for reading environment variables + * + * @since 3.1.0 */ -class WebToPay_PaymentMethodListProvider +class WebToPay_EnvReader { - protected int $projectId; - - protected WebToPay_WebClient $webClient; - - /** - * Holds constructed method lists by currency - * - * @var WebToPay_PaymentMethodList[] - */ - protected array $methodListCache = []; - - /** - * Builds various request URLs - */ - protected WebToPay_UrlBuilder $urlBuilder; - - /** - * Constructs object - * - * @throws WebToPayException if SimpleXML is not available - */ - public function __construct( - int $projectId, - WebToPay_WebClient $webClient, - WebToPay_UrlBuilder $urlBuilder - ) { - $this->projectId = $projectId; - $this->webClient = $webClient; - $this->urlBuilder = $urlBuilder; - - if (!WebToPay_Functions::function_exists('simplexml_load_string')) { - throw new WebToPayException('You have to install libxml to use payment methods API'); - } - } - /** - * Gets payment method list for specified currency - * - * @throws WebToPayException + * @param string $key + * @param string|null $default + * @return string|null */ - public function getPaymentMethodList(?float $amount, ?string $currency): WebToPay_PaymentMethodList + public function getAsString(string $key, string $default = null): ?string { - if (!isset($this->methodListCache[$currency])) { - $xmlAsString = $this->webClient->get( - $this->urlBuilder->buildForPaymentsMethodList($this->projectId, (string) $amount, $currency) - ); - $useInternalErrors = libxml_use_internal_errors(false); - $rootNode = simplexml_load_string($xmlAsString); - libxml_clear_errors(); - libxml_use_internal_errors($useInternalErrors); - if (!$rootNode) { - throw new WebToPayException('Unable to load XML from remote server'); - } - $methodList = new WebToPay_PaymentMethodList($this->projectId, $currency); - $methodList->fromXmlNode($rootNode); - $this->methodListCache[$currency] = $methodList; + if (!empty($_ENV[$key])) { + return (string)$_ENV[$key]; } - return $this->methodListCache[$currency]; + $value = (string)getenv($key); + + return !empty($value) + ? $value + : $default; } } /** - * Creates objects. Also caches to avoid creating several instances of same objects + * Initializes configurations for WebToPay and WebToPay_Factory + * + * @since 3.1.0 */ -class WebToPay_Factory +class WebToPay_Config { - public const ENV_PRODUCTION = 'production'; - public const ENV_SANDBOX = 'sandbox'; + public const PRODUCTION = 'production'; - /** - * @var array - */ - protected static array $defaultConfiguration = [ - 'routes' => [ - self::ENV_PRODUCTION => [ - 'publicKey' => 'https://www.paysera.com/download/public.key', - 'payment' => 'https://bank.paysera.com/pay/', - 'paymentMethodList' => 'https://www.paysera.com/new/api/paymentMethods/', - 'smsAnswer' => 'https://bank.paysera.com/psms/respond/', - ], - self::ENV_SANDBOX => [ - 'publicKey' => 'https://sandbox.paysera.com/download/public.key', - 'payment' => 'https://sandbox.paysera.com/pay/', - 'paymentMethodList' => 'https://sandbox.paysera.com/new/api/paymentMethods/', - 'smsAnswer' => 'https://sandbox.paysera.com/psms/respond/', - ], - ], - ]; + public const SANDBOX = 'sandbox'; - protected string $environment; + public const PARAM_PROJECT_ID = 'projectId'; - /** - * @var array - */ - protected array $configuration; + public const PARAM_PASSWORD = 'password'; - protected ?WebToPay_WebClient $webClient = null; + public const PARAM_PAY_URL = 'payUrl'; - protected ?WebToPay_CallbackValidator $callbackValidator = null; + public const PARAM_PAYSERA_PAY_URL = 'payseraPayUrl'; - protected ?WebToPay_RequestBuilder $requestBuilder = null; + public const PARAM_XML_URL = 'xmlUrl'; - protected ?WebToPay_Sign_SignCheckerInterface $signer = null; + public const PARAM_ROUTES = 'routes'; - protected ?WebToPay_SmsAnswerSender $smsAnswerSender = null; + protected const ENV_VAR_PAY_URL = 'PAY_URL'; - protected ?WebToPay_PaymentMethodListProvider $paymentMethodListProvider = null; + protected const ENV_VAR_PAYSERA_PAY_URL = 'PAYSERA_PAY_URL'; - protected ?WebToPay_Util $util = null; + protected const ENV_VAR_XML_URL = 'XML_URL'; - protected ?WebToPay_UrlBuilder $urlBuilder = null; + protected const PARAMS_TO_ENV_VARS_MAP = [ + self::PARAM_PROJECT_ID => null, + self::PARAM_PASSWORD => null, + self::PARAM_PAY_URL => self::ENV_VAR_PAY_URL, + self::PARAM_PAYSERA_PAY_URL => self::ENV_VAR_PAYSERA_PAY_URL, + self::PARAM_XML_URL => self::ENV_VAR_XML_URL, + ]; + + protected const DEFAULT_VALUES = [ + self::PARAM_PROJECT_ID => null, + self::PARAM_PASSWORD => null, + self::PARAM_PAY_URL => 'https://bank.paysera.com/pay/', + self::PARAM_PAYSERA_PAY_URL => 'https://bank.paysera.com/pay/', + self::PARAM_XML_URL => 'https://www.paysera.com/new/api/paymentMethods/', + ]; + + protected const DEFAULT_ROUTES = [ + self::PRODUCTION => [ + WebToPay_Routes::ROUTE_PUBLIC_KEY => 'https://www.paysera.com/download/public.key', + WebToPay_Routes::ROUTE_PAYMENT => 'https://bank.paysera.com/pay/', + WebToPay_Routes::ROUTE_PAYMENT_METHOD_LIST => 'https://www.paysera.com/new/api/paymentMethods/', + WebToPay_Routes::ROUTE_SMS_ANSWER => 'https://bank.paysera.com/psms/respond/', + ], + self::SANDBOX => [ + WebToPay_Routes::ROUTE_PUBLIC_KEY => 'https://sandbox.paysera.com/download/public.key', + WebToPay_Routes::ROUTE_PAYMENT => 'https://sandbox.paysera.com/pay/', + WebToPay_Routes::ROUTE_PAYMENT_METHOD_LIST => 'https://sandbox.paysera.com/new/api/paymentMethods/', + WebToPay_Routes::ROUTE_SMS_ANSWER => 'https://sandbox.paysera.com/psms/respond/', + ], + ]; + + private WebToPay_EnvReader $envReader; + + protected string $environment = self::PRODUCTION; + + protected array $customParams = []; + + protected ?int $projectId = null; + + protected ?string $password = null; /** - * Constructs object. - * Configuration keys: projectId, password - * They are required only when some object being created needs them, - * if they are not found at that moment - exception is thrown - * - * @param array $configuration + * Server URL where all requests should go. */ - public function __construct(array $configuration = []) - { - $this->configuration = array_merge(self::$defaultConfiguration, $configuration); - $this->environment = self::ENV_PRODUCTION; - } + protected string $payUrl; /** - * If passed true the factory will use sandbox when constructing URLs + * Server URL where all non-lithuanian language requests should go. */ - public function useSandbox(bool $enableSandbox): self - { - if ($enableSandbox) { - $this->environment = self::ENV_SANDBOX; - } else { - $this->environment = self::ENV_PRODUCTION; - } - - return $this; - } + protected string $payseraPayUrl; /** - * Creates or gets callback validator instance - * - * @throws WebToPayException - * @throws WebToPay_Exception_Configuration + * Server URL where we can get XML with payment method data. */ - public function getCallbackValidator(): WebToPay_CallbackValidator - { - if ($this->callbackValidator === null) { - if (!isset($this->configuration['projectId'])) { - throw new WebToPay_Exception_Configuration('You have to provide project ID'); - } + protected string $xmlUrl; - $this->callbackValidator = new WebToPay_CallbackValidator( - (int) $this->configuration['projectId'], - $this->getSigner(), - $this->getUtil(), - $this->configuration['password'] ?? null - ); - } + protected WebToPay_Routes $routes; - return $this->callbackValidator; + public function __construct( + WebToPay_EnvReader $envReader, + string $environment = self::PRODUCTION, + array $customParams = [] + ) { + $this->envReader = $envReader; + $this->environment = $environment; + $this->customParams = $customParams; + + $this->initConfig(); } - /** - * Creates or gets request builder instance - * - * @throws WebToPay_Exception_Configuration - */ - public function getRequestBuilder(): WebToPay_RequestBuilder + public function getProjectId(): ?int { - if ($this->requestBuilder === null) { - if (!isset($this->configuration['password'])) { - throw new WebToPay_Exception_Configuration('You have to provide project password to sign request'); - } - if (!isset($this->configuration['projectId'])) { - throw new WebToPay_Exception_Configuration('You have to provide project ID'); - } - $this->requestBuilder = new WebToPay_RequestBuilder( - (int) $this->configuration['projectId'], - $this->configuration['password'], - $this->getUtil(), - $this->getUrlBuilder() - ); - } - - return $this->requestBuilder; + return $this->projectId; } - public function getUrlBuilder(): WebToPay_UrlBuilder + public function getPassword(): ?string { - if ($this->urlBuilder === null || $this->urlBuilder->getEnvironment() !== $this->environment) { - $this->urlBuilder = new WebToPay_UrlBuilder( - $this->configuration, - $this->environment - ); - } + return $this->password; + } - return $this->urlBuilder; + public function getPayUrl(): string + { + return $this->payUrl; } - /** - * Creates or gets SMS answer sender instance - * - * @throws WebToPay_Exception_Configuration - */ - public function getSmsAnswerSender(): WebToPay_SmsAnswerSender + public function getPayseraPayUrl(): string { - if ($this->smsAnswerSender === null) { - if (!isset($this->configuration['password'])) { - throw new WebToPay_Exception_Configuration('You have to provide project password'); - } - $this->smsAnswerSender = new WebToPay_SmsAnswerSender( - $this->configuration['password'], - $this->getWebClient(), - $this->getUrlBuilder() - ); - } + return $this->payseraPayUrl; + } - return $this->smsAnswerSender; + public function getXmlUrl(): string + { + return $this->xmlUrl; } - /** - * Creates or gets payment list provider instance - * - * @throws WebToPayException - * @throws WebToPay_Exception_Configuration - */ - public function getPaymentMethodListProvider(): WebToPay_PaymentMethodListProvider + public function getRoutes(): WebToPay_Routes { - if ($this->paymentMethodListProvider === null) { - if (!isset($this->configuration['projectId'])) { - throw new WebToPay_Exception_Configuration('You have to provide project ID'); - } - $this->paymentMethodListProvider = new WebToPay_PaymentMethodListProvider( - (int) $this->configuration['projectId'], - $this->getWebClient(), - $this->getUrlBuilder() - ); - } + return $this->routes; + } - return $this->paymentMethodListProvider; + public function switchEnvironment(string $environment): void + { + $this->environment = $environment; + $this->initRoutes(); } - /** - * Creates or gets signer instance. Chooses SS2 signer if openssl functions are available, SS1 in other case - * - * @throws WebToPay_Exception_Configuration - * @throws WebToPayException - */ - protected function getSigner(): WebToPay_Sign_SignCheckerInterface + protected function initConfig(): void { - if ($this->signer === null) { - if (WebToPay_Functions::function_exists('openssl_pkey_get_public')) { - $webClient = $this->getWebClient(); - $publicKey = $webClient->get($this->getUrlBuilder()->buildForPublicKey()); - if (!$publicKey) { - throw new WebToPayException('Cannot download public key from WebToPay website'); - } - $this->signer = new WebToPay_Sign_SS2SignChecker($publicKey, $this->getUtil()); - } else { - if (!isset($this->configuration['password'])) { - throw new WebToPay_Exception_Configuration( - 'You have to provide project password if OpenSSL is unavailable' - ); - } - $this->signer = new WebToPay_Sign_SS1SignChecker($this->configuration['password']); - } + foreach (self::PARAMS_TO_ENV_VARS_MAP as $targetProperty => $envName) { + $this->initProperty($targetProperty, $envName); } - return $this->signer; + $this->initRoutes(); } - /** - * Creates or gets web client instance - */ - protected function getWebClient(): WebToPay_WebClient + protected function initRoutes(): void { - if ($this->webClient === null) { - $this->webClient = new WebToPay_WebClient(); + $this->routes = new WebToPay_Routes( + $this->envReader, + $this->environment, + static::DEFAULT_ROUTES[$this->environment] ?? [], + $this->customParams[static::PARAM_ROUTES] ?? [], + ); + } + + protected function initProperty(string $targetProperty, ?string $envName): void + { + if (!property_exists($this, $targetProperty)) { + return; } - return $this->webClient; + if ($this->initCustomVar($targetProperty)) { + return; + } + + if ($envName === null) { + return; + } + + $this->initEnvVar($envName, $targetProperty); } - /** - * Creates or gets util instance - * - * @throws WebToPay_Exception_Configuration - */ - protected function getUtil(): WebToPay_Util + protected function initCustomVar($targetProperty): bool { - if ($this->util === null) { - $this->util = new WebToPay_Util(); + if (!empty($this->customParams[$targetProperty])) { + $this->{$targetProperty} = $this->customParams[$targetProperty]; + + return true; } - return $this->util; + return false; + } + + protected function initEnvVar(string $varName, string $targetProperty): void + { + $this->{$targetProperty} = $this->envReader->getAsString($varName, static::DEFAULT_VALUES[$targetProperty]); } } /** - * Used to build a complete request URL. + * Representation of routes configurations for WebToPay_Factory * - * Class WebToPay_UrlBuilder + * @since 3.1.0 */ -class WebToPay_UrlBuilder +class WebToPay_Routes { - public const PLACEHOLDER_KEY = '[domain]'; + public const ROUTE_PUBLIC_KEY = 'publicKey'; + + public const ROUTE_PAYMENT = 'payment'; + + public const ROUTE_PAYMENT_METHOD_LIST = 'paymentMethodList'; + + public const ROUTE_SMS_ANSWER = 'smsAnswer'; + + protected const ENV_VAR_PUBLIC_KEY = 'PUBLIC_KEY'; + + protected const ENV_VAR_PAYMENT = 'PAYMENT'; + + protected const ENV_VAR_PAYMENT_METHOD_LIST = 'PAYMENT_METHOD_LIST'; + + protected const ENV_VAR_SMS_ANSWER = 'SMS_ANSWER'; + + protected const ROUTES_TO_ENV_VARS_MAP = [ + self::ROUTE_PUBLIC_KEY => self::ENV_VAR_PUBLIC_KEY, + self::ROUTE_PAYMENT => self::ENV_VAR_PAYMENT, + self::ROUTE_PAYMENT_METHOD_LIST => self::ENV_VAR_PAYMENT_METHOD_LIST, + self::ROUTE_SMS_ANSWER => self::ENV_VAR_SMS_ANSWER, + ]; + + protected const ENV_VARS_DEFAULTS = [ + self::ROUTE_PUBLIC_KEY => '', + self::ROUTE_PAYMENT => '', + self::ROUTE_PAYMENT_METHOD_LIST => '', + self::ROUTE_SMS_ANSWER => '', + ]; + + protected string $envPrefix = WebToPay_Config::PRODUCTION; + + protected array $defaults = []; + + protected array $customRoutes = []; + + protected string $publicKey; + + + protected string $payment; + + protected string $paymentMethodList; + + protected string $smsAnswer; + + private WebToPay_EnvReader $envReader; /** - * @var array + * @throws Exception */ - protected array $configuration; + public function __construct( + WebToPay_EnvReader $envReader, + string $envPrefix, + array $defaults = [], + array $customRoutes = [] + ) { + $this->envReader = $envReader; + $this->envPrefix = $envPrefix; + $this->defaults = $defaults; + $this->customRoutes = $customRoutes; + + $this->initConfig(); + } + + public function getPublicKeyRoute(): string + { + return $this->publicKey; + } + + public function getPaymentRoute(): string + { + return $this->payment; + } - protected string $environment; + public function getPaymentMethodListRoute(): string + { + return $this->paymentMethodList; + } - /** - * @var array - */ - protected array $environmentSettings; + public function getSmsAnswerRoute(): string + { + return $this->smsAnswer; + } - /** - * @param array $configuration - * @param string $environment - */ - public function __construct(array $configuration, string $environment) + protected function initConfig(): void { - $this->configuration = $configuration; - $this->environment = $environment; - $this->environmentSettings = $this->configuration['routes'][$this->environment]; + $envKeyTemplate = strtoupper($this->envPrefix) . '_%s'; + + foreach (static::ROUTES_TO_ENV_VARS_MAP as $targetProperty => $varName) { + $this->initProperty($targetProperty, $varName, $envKeyTemplate); + } } - public function getEnvironment(): string + protected function initProperty(string $targetProperty, ?string $envName, string $envKeyTemplate): void { - return $this->environment; + if (!property_exists($this, $targetProperty)) { + return; + } + + if ($this->initCustomValue($targetProperty)) { + return; + } + + if ($envName === null) { + return; + } + + $this->initEnvVar($envName, $targetProperty, $envKeyTemplate); } - /** - * Builds a complete request URL based on the provided parameters - * - * @param array $request - * - * @return string - */ - public function buildForRequest(array $request): string + protected function initCustomValue(string $targetProperty): bool { - return $this->createUrlFromRequestAndLanguage($request); + if (isset($this->customRoutes[$targetProperty])) { + $this->{$targetProperty} = $this->customRoutes[$targetProperty]; + + return true; + } + + return false; } - /** - * Builds a complete URL for payment list API - */ - public function buildForPaymentsMethodList(int $projectId, ?string $amount, ?string $currency): string + protected function initEnvVar(string $varName, string $targetProperty, string $envKeyTemplate): void { - $route = $this->environmentSettings['paymentMethodList']; + $envVar = sprintf($envKeyTemplate, $varName); - return $route . $projectId . '/currency:' . $currency . '/amount:' . $amount; + $this->{$targetProperty} = $this->envReader->getAsString( + $envVar, + $this->defaults[$targetProperty] ?? static::ENV_VARS_DEFAULTS[$targetProperty] + ); } +} + + +/** + * Loads data about payment methods and constructs payment method list object from that data + * You need SimpleXML support to use this feature + */ +class WebToPay_PaymentMethodListProvider +{ + protected int $projectId; + + protected WebToPay_WebClient $webClient; /** - * Builds a complete URL for Sms Answer + * Holds constructed method lists by currency * - * @codeCoverageIgnore + * @var WebToPay_PaymentMethodList[] */ - public function buildForSmsAnswer(): string - { - return $this->environmentSettings['smsAnswer']; - } + protected array $methodListCache = []; /** - * Build the URL to the public key + * Builds various request URLs */ - public function buildForPublicKey(): string - { - return $this->environmentSettings['publicKey']; - } + protected WebToPay_UrlBuilder $urlBuilder; /** - * Creates a URL from the request and data provided. - * - * @param array $request + * Constructs object * - * @return string + * @throws WebToPayException if SimpleXML is not available */ - protected function createUrlFromRequestAndLanguage(array $request): string - { - $url = $this->getPaymentUrl() . '?' . http_build_query($request, '', '&'); + public function __construct( + int $projectId, + WebToPay_WebClient $webClient, + WebToPay_UrlBuilder $urlBuilder + ) { + $this->projectId = $projectId; + $this->webClient = $webClient; + $this->urlBuilder = $urlBuilder; - return preg_replace('/[\r\n]+/is', '', $url) ?? ''; + if (!WebToPay_Functions::function_exists('simplexml_load_string')) { + throw new WebToPayException('You have to install libxml to use payment methods API'); + } } /** - * Returns payment URL. Argument is same as lang parameter in request data + * Gets payment method list for specified currency * - * @return string + * @throws WebToPayException */ - public function getPaymentUrl(): string + public function getPaymentMethodList(?float $amount, ?string $currency): WebToPay_PaymentMethodList { - return $this->environmentSettings['payment']; + if (!isset($this->methodListCache[$currency])) { + $xmlAsString = $this->webClient->get( + $this->urlBuilder->buildForPaymentsMethodList($this->projectId, (string) $amount, $currency) + ); + $useInternalErrors = libxml_use_internal_errors(false); + $rootNode = simplexml_load_string($xmlAsString); + libxml_clear_errors(); + libxml_use_internal_errors($useInternalErrors); + if (!$rootNode) { + throw new WebToPayException('Unable to load XML from remote server'); + } + $methodList = new WebToPay_PaymentMethodList($this->projectId, $currency); + $methodList->fromXmlNode($rootNode); + $this->methodListCache[$currency] = $methodList; + } + + return $this->methodListCache[$currency]; } } @@ -2170,123 +2595,92 @@ protected function createPaymentMethod( /** - * Parses and validates callbacks + * Checks SS2 signature. Depends on SSL functions */ -class WebToPay_CallbackValidator +class WebToPay_Sign_SS2SignChecker implements WebToPay_Sign_SignCheckerInterface { - protected WebToPay_Sign_SignCheckerInterface $signer; + protected string $publicKey; protected WebToPay_Util $util; - protected int $projectId; - - protected ?string $password; - /** * Constructs object - * - * @param integer $projectId - * @param WebToPay_Sign_SignCheckerInterface $signer - * @param WebToPay_Util $util - * @param string|null $password */ - public function __construct( - int $projectId, - WebToPay_Sign_SignCheckerInterface $signer, - WebToPay_Util $util, - ?string $password = null - ) { - $this->signer = $signer; + public function __construct(string $publicKey, WebToPay_Util $util) + { + $this->publicKey = $publicKey; $this->util = $util; - $this->projectId = $projectId; - $this->password = $password; } /** - * Parses callback parameters from query parameters and checks if sign is correct. - * Request has parameter "data", which is signed and holds all callback parameters + * Checks signature * - * @param array $requestData + * @param array $request * - * @return array Parsed callback parameters + * @return bool * - * @throws WebToPayException * @throws WebToPay_Exception_Callback */ - public function validateAndParseData(array $requestData): array + public function checkSign(array $request): bool { - if (!isset($requestData['data'])) { - throw new WebToPay_Exception_Callback('"data" parameter not found'); + if (!isset($request['data']) || !isset($request['ss2'])) { + throw new WebToPay_Exception_Callback('Not enough parameters in callback. Possible version mismatch'); } - $data = $requestData['data']; - - if (isset($requestData['ss1']) || isset($requestData['ss2'])) { - if (!$this->signer->checkSign($requestData)) { - throw new WebToPay_Exception_Callback('Invalid sign parameters, check $_GET length limit'); - } - - $queryString = $this->util->decodeSafeUrlBase64($data); - } else { - if (null === $this->password) { - throw new WebToPay_Exception_Configuration('You have to provide project password'); - } - - $queryString = $this->util->decryptGCM( - $this->util->decodeSafeUrlBase64($data), - $this->password - ); - - if (null === $queryString) { - throw new WebToPay_Exception_Callback('Callback data decryption failed'); - } - } - $request = $this->util->parseHttpQuery($queryString); + $ss2 = $this->util->decodeSafeUrlBase64($request['ss2']); + $ok = openssl_verify($request['data'], $ss2, $this->publicKey); - if (!isset($request['projectid'])) { - throw new WebToPay_Exception_Callback( - 'Project ID not provided in callback', - WebToPayException::E_INVALID - ); - } + return $ok === 1; + } +} - if ((string) $request['projectid'] !== (string) $this->projectId) { - throw new WebToPay_Exception_Callback( - sprintf('Bad projectid: %s, should be: %s', $request['projectid'], $this->projectId), - WebToPayException::E_INVALID - ); - } - if (!isset($request['type']) || !in_array($request['type'], ['micro', 'macro'], true)) { - $micro = ( - isset($request['to']) - && isset($request['from']) - && isset($request['sms']) - ); - $request['type'] = $micro ? 'micro' : 'macro'; - } +/** + * Sign checker which checks SS1 signature. SS1 does not depend on SSL functions + */ +class WebToPay_Sign_SS1SignChecker implements WebToPay_Sign_SignCheckerInterface +{ + protected string $projectPassword; - return $request; + /** + * Constructs object + */ + public function __construct(string $projectPassword) + { + $this->projectPassword = $projectPassword; } /** - * Checks data to have all the same parameters provided in expected array + * Check for SS1, which is not depend on openssl functions. * - * @param array $data - * @param array $expected + * @param array $request * - * @throws WebToPayException + * @return bool + * + * @throws WebToPay_Exception_Callback */ - public function checkExpectedFields(array $data, array $expected): void + public function checkSign(array $request): bool { - foreach ($expected as $key => $value) { - $passedValue = $data[$key] ?? null; - // there should be non-strict comparison here - if ($passedValue != $value) { - throw new WebToPayException( - sprintf('Field %s is not as expected (expected %s, got %s)', $key, $value, $passedValue) - ); - } + if (!isset($request['data']) || !isset($request['ss1'])) { + throw new WebToPay_Exception_Callback('Not enough parameters in callback. Possible version mismatch'); } + + return md5($request['data'] . $this->projectPassword) === $request['ss1']; } } + + +/** + * Interface for sign checker + */ +interface WebToPay_Sign_SignCheckerInterface +{ + /** + * Checks whether request is signed properly + * + * @param array $request + * + * @return boolean + */ + public function checkSign(array $request): bool; +} diff --git a/composer.json b/composer.json index 90620a1..9a630ad 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "webtopay/libwebtopay", "description": "PHP Library for Paysera payment gateway integration", - "version": "3.0.1", + "version": "3.1.0", "license": "LGPL-3.0", "authors": [ { @@ -26,7 +26,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-phpunit": "^1.3", "mockery/mockery": "^1.6", - "ext-xdebug": "*" + "ext-xdebug": "*", + "symfony/dotenv": "^5" }, "scripts": { "phpunit": "php ./vendor/phpunit/phpunit/phpunit tests" diff --git a/composer.lock b/composer.lock index 89bd9cb..2eebc55 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "091795cf779a06840a217f54c04215c5", + "content-hash": "ab9365a1d3e4fc7e7b953b83c721a9ac", "packages": [], "packages-dev": [ { @@ -2708,6 +2708,77 @@ ], "time": "2023-01-24T14:02:46+00:00" }, + { + "name": "symfony/dotenv", + "version": "v5.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "bb4fef2bf035a50170fd95e5b146152834126008" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/bb4fef2bf035a50170fd95e5b146152834126008", + "reference": "bb4fef2bf035a50170fd95e5b146152834126008", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3" + }, + "require-dev": { + "symfony/console": "^4.4|^5.0|^6.0", + "symfony/process": "^4.4|^5.0|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v5.4.44" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-16T09:39:25+00:00" + }, { "name": "symfony/event-dispatcher", "version": "v5.4.35", diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3901191 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 0 + ignoreErrors: + - '#Fetching deprecated class constant .*#' + paths: + - src/ \ No newline at end of file diff --git a/src/WebToPay.php b/src/WebToPay.php index 5d1cdc9..bd4c599 100644 --- a/src/WebToPay.php +++ b/src/WebToPay.php @@ -34,20 +34,29 @@ class WebToPay /** * WebToPay Library version. */ - public const VERSION = '3.0.1'; + public const VERSION = '3.1.0'; /** * Server URL where all requests should go. + * + * @deprecated since 3.0.2 + * @see WebToPay_Config::getPayUrl */ public const PAY_URL = 'https://bank.paysera.com/pay/'; /** * Server URL where all non-lithuanian language requests should go. + * + * @deprecated since 3.0.2 + * @see WebToPay_Config::getPayseraPayUrl */ public const PAYSERA_PAY_URL = 'https://bank.paysera.com/pay/'; /** * Server URL where we can get XML with payment method data. + * + * @deprecated since 3.0.2 + * @see WebToPay_Config::getXmlUrl */ public const XML_URL = 'https://www.paysera.com/new/api/paymentMethods/'; @@ -82,7 +91,12 @@ public static function buildRequest(array $data): array unset($data['sign_password']); unset($data['projectid']); - $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); + $factory = new WebToPay_Factory( + [ + WebToPay_Config::PARAM_PROJECT_ID => (int)$projectId, + WebToPay_Config::PARAM_PASSWORD => $password, + ] + ); $requestBuilder = $factory->getRequestBuilder(); return $requestBuilder->buildRequest($data); @@ -94,8 +108,8 @@ public static function buildRequest(array $data): array * Possible array keys are described here: * https://developers.paysera.com/en/checkout/integrations/integration-specification * - * @param array $data Information about current payment request. - * @param boolean $exit if true, exits after sending Location header; default false + * @param array $data Information about current payment request. + * @param boolean $exit if true, exits after sending Location header; default false * * @throws WebToPayException on data validation error */ @@ -108,7 +122,12 @@ public static function redirectToPayment(array $data, bool $exit = false): void unset($data['sign_password']); unset($data['projectid']); - $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); + $factory = new WebToPay_Factory( + [ + WebToPay_Config::PARAM_PROJECT_ID => (int)$projectId, + WebToPay_Config::PARAM_PASSWORD => $password, + ] + ); $url = $factory->getRequestBuilder() ->buildRequestUrlFromData($data); @@ -139,7 +158,7 @@ public static function redirectToPayment(array $data, bool $exit = false): void * keys are described here: * https://developers.paysera.com/en/checkout/integrations/integration-specification * - * @param array $data Information about current payment request + * @param array $data Information about current payment request * * @return array * @@ -154,7 +173,12 @@ public static function buildRepeatRequest(array $data): array $projectId = $data['projectid']; $orderId = $data['orderid']; - $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); + $factory = new WebToPay_Factory( + [ + WebToPay_Config::PARAM_PROJECT_ID => (int)$projectId, + WebToPay_Config::PARAM_PASSWORD => $password, + ] + ); $requestBuilder = $factory->getRequestBuilder(); return $requestBuilder->buildRepeatRequest($orderId); @@ -168,9 +192,11 @@ public static function buildRepeatRequest(array $data): array */ public static function getPaymentUrl(string $language = 'LIT'): string { + $config = new WebToPay_Config(new WebToPay_EnvReader()); + return (in_array($language, ['lt', 'lit', 'LIT'], true)) - ? self::PAY_URL - : self::PAYSERA_PAY_URL; + ? $config->getPayUrl() + : $config->getPayseraPayUrl(); } /** @@ -188,7 +214,12 @@ public static function getPaymentUrl(string $language = 'LIT'): string */ public static function validateAndParseData(array $query, ?int $projectId, ?string $password): array { - $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); + $factory = new WebToPay_Factory( + [ + WebToPay_Config::PARAM_PROJECT_ID => $projectId, + WebToPay_Config::PARAM_PASSWORD => $password, + ] + ); $validator = $factory->getCallbackValidator(); return $validator->validateAndParseData($query); @@ -217,8 +248,7 @@ public static function smsAnswer(array $userData): void $logFile = $userData['log'] ?? null; try { - - $factory = new WebToPay_Factory(['password' => $password]); + $factory = new WebToPay_Factory([WebToPay_Config::PARAM_PASSWORD => $password]); $factory->getSmsAnswerSender()->sendAnswer($smsId, $text); if ($logFile) { @@ -244,7 +274,7 @@ public static function getPaymentMethodList( ?float $amount, ?string $currency = 'EUR' ): WebToPay_PaymentMethodList { - $factory = new WebToPay_Factory(['projectId' => $projectId]); + $factory = new WebToPay_Factory([WebToPay_Config::PARAM_PROJECT_ID => $projectId]); return $factory->getPaymentMethodListProvider()->getPaymentMethodList($amount, $currency); } @@ -270,13 +300,13 @@ protected static function log(string $type, string $msg, string $logfile): void $msg, ]; - $logline = implode(' ', $logline)."\n"; + $logline = implode(' ', $logline) . "\n"; fwrite($fp, $logline); fclose($fp); // clear big log file if (filesize($logfile) > 1024 * 1024 * pi()) { - copy($logfile, $logfile.'.old'); + copy($logfile, $logfile . '.old'); unlink($logfile); } } diff --git a/src/WebToPay/Config/Config.php b/src/WebToPay/Config/Config.php new file mode 100644 index 0000000..e0b59da --- /dev/null +++ b/src/WebToPay/Config/Config.php @@ -0,0 +1,191 @@ + null, + self::PARAM_PASSWORD => null, + self::PARAM_PAY_URL => self::ENV_VAR_PAY_URL, + self::PARAM_PAYSERA_PAY_URL => self::ENV_VAR_PAYSERA_PAY_URL, + self::PARAM_XML_URL => self::ENV_VAR_XML_URL, + ]; + + protected const DEFAULT_VALUES = [ + self::PARAM_PROJECT_ID => null, + self::PARAM_PASSWORD => null, + self::PARAM_PAY_URL => 'https://bank.paysera.com/pay/', + self::PARAM_PAYSERA_PAY_URL => 'https://bank.paysera.com/pay/', + self::PARAM_XML_URL => 'https://www.paysera.com/new/api/paymentMethods/', + ]; + + protected const DEFAULT_ROUTES = [ + self::PRODUCTION => [ + WebToPay_Routes::ROUTE_PUBLIC_KEY => 'https://www.paysera.com/download/public.key', + WebToPay_Routes::ROUTE_PAYMENT => 'https://bank.paysera.com/pay/', + WebToPay_Routes::ROUTE_PAYMENT_METHOD_LIST => 'https://www.paysera.com/new/api/paymentMethods/', + WebToPay_Routes::ROUTE_SMS_ANSWER => 'https://bank.paysera.com/psms/respond/', + ], + self::SANDBOX => [ + WebToPay_Routes::ROUTE_PUBLIC_KEY => 'https://sandbox.paysera.com/download/public.key', + WebToPay_Routes::ROUTE_PAYMENT => 'https://sandbox.paysera.com/pay/', + WebToPay_Routes::ROUTE_PAYMENT_METHOD_LIST => 'https://sandbox.paysera.com/new/api/paymentMethods/', + WebToPay_Routes::ROUTE_SMS_ANSWER => 'https://sandbox.paysera.com/psms/respond/', + ], + ]; + + private WebToPay_EnvReader $envReader; + + protected string $environment = self::PRODUCTION; + + protected array $customParams = []; + + protected ?int $projectId = null; + + protected ?string $password = null; + + /** + * Server URL where all requests should go. + */ + protected string $payUrl; + + /** + * Server URL where all non-lithuanian language requests should go. + */ + protected string $payseraPayUrl; + + /** + * Server URL where we can get XML with payment method data. + */ + protected string $xmlUrl; + + protected WebToPay_Routes $routes; + + public function __construct( + WebToPay_EnvReader $envReader, + string $environment = self::PRODUCTION, + array $customParams = [] + ) { + $this->envReader = $envReader; + $this->environment = $environment; + $this->customParams = $customParams; + + $this->initConfig(); + } + + public function getProjectId(): ?int + { + return $this->projectId; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getPayUrl(): string + { + return $this->payUrl; + } + + public function getPayseraPayUrl(): string + { + return $this->payseraPayUrl; + } + + public function getXmlUrl(): string + { + return $this->xmlUrl; + } + + public function getRoutes(): WebToPay_Routes + { + return $this->routes; + } + + public function switchEnvironment(string $environment): void + { + $this->environment = $environment; + $this->initRoutes(); + } + + protected function initConfig(): void + { + foreach (self::PARAMS_TO_ENV_VARS_MAP as $targetProperty => $envName) { + $this->initProperty($targetProperty, $envName); + } + + $this->initRoutes(); + } + + protected function initRoutes(): void + { + $this->routes = new WebToPay_Routes( + $this->envReader, + $this->environment, + static::DEFAULT_ROUTES[$this->environment] ?? [], + $this->customParams[static::PARAM_ROUTES] ?? [], + ); + } + + protected function initProperty(string $targetProperty, ?string $envName): void + { + if (!property_exists($this, $targetProperty)) { + return; + } + + if ($this->initCustomVar($targetProperty)) { + return; + } + + if ($envName === null) { + return; + } + + $this->initEnvVar($envName, $targetProperty); + } + + protected function initCustomVar($targetProperty): bool + { + if (!empty($this->customParams[$targetProperty])) { + $this->{$targetProperty} = $this->customParams[$targetProperty]; + + return true; + } + + return false; + } + + protected function initEnvVar(string $varName, string $targetProperty): void + { + $this->{$targetProperty} = $this->envReader->getAsString($varName, static::DEFAULT_VALUES[$targetProperty]); + } +} diff --git a/src/WebToPay/Config/Provider/EnvReader.php b/src/WebToPay/Config/Provider/EnvReader.php new file mode 100644 index 0000000..aadb127 --- /dev/null +++ b/src/WebToPay/Config/Provider/EnvReader.php @@ -0,0 +1,29 @@ + self::ENV_VAR_PUBLIC_KEY, + self::ROUTE_PAYMENT => self::ENV_VAR_PAYMENT, + self::ROUTE_PAYMENT_METHOD_LIST => self::ENV_VAR_PAYMENT_METHOD_LIST, + self::ROUTE_SMS_ANSWER => self::ENV_VAR_SMS_ANSWER, + ]; + + protected const ENV_VARS_DEFAULTS = [ + self::ROUTE_PUBLIC_KEY => '', + self::ROUTE_PAYMENT => '', + self::ROUTE_PAYMENT_METHOD_LIST => '', + self::ROUTE_SMS_ANSWER => '', + ]; + + protected string $envPrefix = WebToPay_Config::PRODUCTION; + + protected array $defaults = []; + + protected array $customRoutes = []; + + protected string $publicKey; + + + protected string $payment; + + protected string $paymentMethodList; + + protected string $smsAnswer; + + private WebToPay_EnvReader $envReader; + + /** + * @throws Exception + */ + public function __construct( + WebToPay_EnvReader $envReader, + string $envPrefix, + array $defaults = [], + array $customRoutes = [] + ) { + $this->envReader = $envReader; + $this->envPrefix = $envPrefix; + $this->defaults = $defaults; + $this->customRoutes = $customRoutes; + + $this->initConfig(); + } + + public function getPublicKeyRoute(): string + { + return $this->publicKey; + } + + public function getPaymentRoute(): string + { + return $this->payment; + } + + public function getPaymentMethodListRoute(): string + { + return $this->paymentMethodList; + } + + public function getSmsAnswerRoute(): string + { + return $this->smsAnswer; + } + + protected function initConfig(): void + { + $envKeyTemplate = strtoupper($this->envPrefix) . '_%s'; + + foreach (static::ROUTES_TO_ENV_VARS_MAP as $targetProperty => $varName) { + $this->initProperty($targetProperty, $varName, $envKeyTemplate); + } + } + + protected function initProperty(string $targetProperty, ?string $envName, string $envKeyTemplate): void + { + if (!property_exists($this, $targetProperty)) { + return; + } + + if ($this->initCustomValue($targetProperty)) { + return; + } + + if ($envName === null) { + return; + } + + $this->initEnvVar($envName, $targetProperty, $envKeyTemplate); + } + + protected function initCustomValue(string $targetProperty): bool + { + if (isset($this->customRoutes[$targetProperty])) { + $this->{$targetProperty} = $this->customRoutes[$targetProperty]; + + return true; + } + + return false; + } + + protected function initEnvVar(string $varName, string $targetProperty, string $envKeyTemplate): void + { + $envVar = sprintf($envKeyTemplate, $varName); + + $this->{$targetProperty} = $this->envReader->getAsString( + $envVar, + $this->defaults[$targetProperty] ?? static::ENV_VARS_DEFAULTS[$targetProperty] + ); + } +} diff --git a/src/WebToPay/Factory.php b/src/WebToPay/Factory.php index 995cc36..d815e0f 100644 --- a/src/WebToPay/Factory.php +++ b/src/WebToPay/Factory.php @@ -7,35 +7,40 @@ */ class WebToPay_Factory { + /** + * @deprecated since 3.0.2 + */ public const ENV_PRODUCTION = 'production'; + /** + * @deprecated since 3.0.2 + */ public const ENV_SANDBOX = 'sandbox'; /** * @var array + * + * @deprecated since 3.0.2 */ protected static array $defaultConfiguration = [ 'routes' => [ self::ENV_PRODUCTION => [ - 'publicKey' => 'https://www.paysera.com/download/public.key', - 'payment' => 'https://bank.paysera.com/pay/', - 'paymentMethodList' => 'https://www.paysera.com/new/api/paymentMethods/', - 'smsAnswer' => 'https://bank.paysera.com/psms/respond/', + 'publicKey' => 'https://www.paysera.com/download/public.key', + 'payment' => 'https://bank.paysera.com/pay/', + 'paymentMethodList' => 'https://www.paysera.com/new/api/paymentMethods/', + 'smsAnswer' => 'https://bank.paysera.com/psms/respond/', ], self::ENV_SANDBOX => [ - 'publicKey' => 'https://sandbox.paysera.com/download/public.key', - 'payment' => 'https://sandbox.paysera.com/pay/', + 'publicKey' => 'https://sandbox.paysera.com/download/public.key', + 'payment' => 'https://sandbox.paysera.com/pay/', 'paymentMethodList' => 'https://sandbox.paysera.com/new/api/paymentMethods/', - 'smsAnswer' => 'https://sandbox.paysera.com/psms/respond/', + 'smsAnswer' => 'https://sandbox.paysera.com/psms/respond/', ], ], ]; protected string $environment; - /** - * @var array - */ - protected array $configuration; + protected WebToPay_Config $configuration; protected ?WebToPay_WebClient $webClient = null; @@ -63,8 +68,12 @@ class WebToPay_Factory */ public function __construct(array $configuration = []) { - $this->configuration = array_merge(self::$defaultConfiguration, $configuration); - $this->environment = self::ENV_PRODUCTION; + $this->environment = WebToPay_Config::PRODUCTION; + $this->configuration = new WebToPay_Config( + new WebToPay_EnvReader(), + $this->environment, + $configuration + ); } /** @@ -73,11 +82,13 @@ public function __construct(array $configuration = []) public function useSandbox(bool $enableSandbox): self { if ($enableSandbox) { - $this->environment = self::ENV_SANDBOX; + $this->environment = WebToPay_Config::SANDBOX; } else { - $this->environment = self::ENV_PRODUCTION; + $this->environment = WebToPay_Config::PRODUCTION; } + $this->configuration->switchEnvironment($this->environment); + return $this; } @@ -90,15 +101,15 @@ public function useSandbox(bool $enableSandbox): self public function getCallbackValidator(): WebToPay_CallbackValidator { if ($this->callbackValidator === null) { - if (!isset($this->configuration['projectId'])) { + if ($this->configuration->getProjectId() === null) { throw new WebToPay_Exception_Configuration('You have to provide project ID'); } $this->callbackValidator = new WebToPay_CallbackValidator( - (int) $this->configuration['projectId'], + $this->configuration->getProjectId(), $this->getSigner(), $this->getUtil(), - $this->configuration['password'] ?? null + $this->configuration->getPassword() ); } @@ -113,15 +124,15 @@ public function getCallbackValidator(): WebToPay_CallbackValidator public function getRequestBuilder(): WebToPay_RequestBuilder { if ($this->requestBuilder === null) { - if (!isset($this->configuration['password'])) { + if ($this->configuration->getPassword() === null) { throw new WebToPay_Exception_Configuration('You have to provide project password to sign request'); } - if (!isset($this->configuration['projectId'])) { + if ($this->configuration->getProjectId() === null) { throw new WebToPay_Exception_Configuration('You have to provide project ID'); } $this->requestBuilder = new WebToPay_RequestBuilder( - (int) $this->configuration['projectId'], - $this->configuration['password'], + $this->configuration->getProjectId(), + $this->configuration->getPassword(), $this->getUtil(), $this->getUrlBuilder() ); @@ -150,11 +161,11 @@ public function getUrlBuilder(): WebToPay_UrlBuilder public function getSmsAnswerSender(): WebToPay_SmsAnswerSender { if ($this->smsAnswerSender === null) { - if (!isset($this->configuration['password'])) { + if ($this->configuration->getPassword() === null) { throw new WebToPay_Exception_Configuration('You have to provide project password'); } $this->smsAnswerSender = new WebToPay_SmsAnswerSender( - $this->configuration['password'], + $this->configuration->getPassword(), $this->getWebClient(), $this->getUrlBuilder() ); @@ -172,11 +183,11 @@ public function getSmsAnswerSender(): WebToPay_SmsAnswerSender public function getPaymentMethodListProvider(): WebToPay_PaymentMethodListProvider { if ($this->paymentMethodListProvider === null) { - if (!isset($this->configuration['projectId'])) { + if ($this->configuration->getProjectId() === null) { throw new WebToPay_Exception_Configuration('You have to provide project ID'); } $this->paymentMethodListProvider = new WebToPay_PaymentMethodListProvider( - (int) $this->configuration['projectId'], + $this->configuration->getProjectId(), $this->getWebClient(), $this->getUrlBuilder() ); @@ -202,12 +213,12 @@ protected function getSigner(): WebToPay_Sign_SignCheckerInterface } $this->signer = new WebToPay_Sign_SS2SignChecker($publicKey, $this->getUtil()); } else { - if (!isset($this->configuration['password'])) { + if ($this->configuration->getPassword() === null) { throw new WebToPay_Exception_Configuration( 'You have to provide project password if OpenSSL is unavailable' ); } - $this->signer = new WebToPay_Sign_SS1SignChecker($this->configuration['password']); + $this->signer = new WebToPay_Sign_SS1SignChecker($this->configuration->getPassword()); } } diff --git a/src/WebToPay/UrlBuilder.php b/src/WebToPay/UrlBuilder.php index c2ec5a3..54b0953 100644 --- a/src/WebToPay/UrlBuilder.php +++ b/src/WebToPay/UrlBuilder.php @@ -11,27 +11,24 @@ class WebToPay_UrlBuilder { public const PLACEHOLDER_KEY = '[domain]'; - /** - * @var array - */ - protected array $configuration; + protected WebToPay_Config $configuration; protected string $environment; /** * @var array */ - protected array $environmentSettings; + protected WebToPay_Routes $routes; /** - * @param array $configuration + * @param WebToPay_Config $configuration * @param string $environment */ - public function __construct(array $configuration, string $environment) + public function __construct(WebToPay_Config $configuration, string $environment) { $this->configuration = $configuration; $this->environment = $environment; - $this->environmentSettings = $this->configuration['routes'][$this->environment]; + $this->routes = $this->configuration->getRoutes(); } public function getEnvironment(): string @@ -56,7 +53,7 @@ public function buildForRequest(array $request): string */ public function buildForPaymentsMethodList(int $projectId, ?string $amount, ?string $currency): string { - $route = $this->environmentSettings['paymentMethodList']; + $route = $this->routes->getPaymentMethodListRoute(); return $route . $projectId . '/currency:' . $currency . '/amount:' . $amount; } @@ -68,7 +65,7 @@ public function buildForPaymentsMethodList(int $projectId, ?string $amount, ?str */ public function buildForSmsAnswer(): string { - return $this->environmentSettings['smsAnswer']; + return $this->routes->getSmsAnswerRoute(); } /** @@ -76,7 +73,7 @@ public function buildForSmsAnswer(): string */ public function buildForPublicKey(): string { - return $this->environmentSettings['publicKey']; + return $this->routes->getPublicKeyRoute(); } /** @@ -100,6 +97,6 @@ protected function createUrlFromRequestAndLanguage(array $request): string */ public function getPaymentUrl(): string { - return $this->environmentSettings['payment']; + return $this->routes->getPaymentRoute(); } } diff --git a/src/includes.php b/src/includes.php index 21880da..4038cc5 100644 --- a/src/includes.php +++ b/src/includes.php @@ -3,6 +3,8 @@ declare(strict_types=1); // @codeCoverageIgnoreStart if (!class_exists('WebToPay')) { + include(dirname(__FILE__) . '/WebToPay/Config/Routes.php'); + include(dirname(__FILE__) . '/WebToPay/Config/Config.php'); include(dirname(__FILE__) . '/WebToPay.php'); include(dirname(__FILE__) . '/WebToPayException.php'); include(dirname(__FILE__) . '/WebToPay/Exception/Callback.php'); diff --git a/tests/StaticMethods/FactoryTest.php b/tests/StaticMethods/FactoryTest.php index a02e47b..187f72d 100644 --- a/tests/StaticMethods/FactoryTest.php +++ b/tests/StaticMethods/FactoryTest.php @@ -18,12 +18,12 @@ class StaticMethods_FactoryCase extends AbstractTestCase public function setUp(): void { $this->factory = new WebToPay_Factory([ - 'projectId' => '123', + 'projectId' => 123, 'password' => 'abc', ]); $this->factoryWithoutPasswordInConfiguration = new WebToPay_Factory([ - 'projectId' => '123', + 'projectId' => 123, ]); } @@ -73,7 +73,7 @@ public function testGetCallbackValidator_CorrectConfiguration_OpenSslExists_NoPu ->willReturn(''); $factoryMock = $this->getMockBuilder(WebToPay_Factory::class) - ->setConstructorArgs([['projectId' => '123', 'password' => 'abc']]) + ->setConstructorArgs([['projectId' => 123, 'password' => 'abc']]) ->onlyMethods(['getWebClient']) ->getMock(); $factoryMock->expects($this->once()) diff --git a/tests/WebToPay/Config/.base.env b/tests/WebToPay/Config/.base.env new file mode 100644 index 0000000..ddbbf3c --- /dev/null +++ b/tests/WebToPay/Config/.base.env @@ -0,0 +1,8 @@ +PAY_URL=https://test.paysera.net/pay/ +PAYSERA_PAY_URL=https://test.paysera.net/paysera_pay/ +XML_URL=https://test.paysera.net/xml/ + +SANDBOX_PUBLIC_KEY=https://test.paysera.net/sandbox/public_key/ +SANDBOX_PAYMENT=https://test.paysera.net/sandbox/payment/ +SANDBOX_PAYMENT_METHOD_LIST=https://test.paysera.net/sandbox/paument_method_list/ +SANDBOX_SMS_ANSWER=https://test.paysera.net/sandbox/sms_answer/ \ No newline at end of file diff --git a/tests/WebToPay/Config/.non-full-routes-test.env b/tests/WebToPay/Config/.non-full-routes-test.env new file mode 100644 index 0000000..bb9f3ab --- /dev/null +++ b/tests/WebToPay/Config/.non-full-routes-test.env @@ -0,0 +1,2 @@ +TEST_PUBLIC_KEY=https://public-key-test.paysera.net/ +TEST_PAYMENT=https://payment.paysera.net/ \ No newline at end of file diff --git a/tests/WebToPay/Config/.non-full.env b/tests/WebToPay/Config/.non-full.env new file mode 100644 index 0000000..ec92554 --- /dev/null +++ b/tests/WebToPay/Config/.non-full.env @@ -0,0 +1,5 @@ +PAYSERA_PAY_URL=https://test.paysera.net/paysera_pay/ +XML_URL=https://test.paysera.net/xml/ + +SANDBOX_PUBLIC_KEY=https://test.paysera.net/sandbox/public_key/ +SANDBOX_PAYMENT=https://test.paysera.net/sandbox/payment/ diff --git a/tests/WebToPay/Config/.routes-test.env b/tests/WebToPay/Config/.routes-test.env new file mode 100644 index 0000000..a6bebc1 --- /dev/null +++ b/tests/WebToPay/Config/.routes-test.env @@ -0,0 +1,4 @@ +TEST_PUBLIC_KEY=https://public-key-test.paysera.net/ +TEST_PAYMENT=https://payment.paysera.net/ +TEST_PAYMENT_METHOD_LIST=https://payment-method-list.paysera.net/ +TEST_SMS_ANSWER=https://sms-answer.paysera.net/ \ No newline at end of file diff --git a/tests/WebToPay/Config/.switch-envs.env b/tests/WebToPay/Config/.switch-envs.env new file mode 100644 index 0000000..50b72bf --- /dev/null +++ b/tests/WebToPay/Config/.switch-envs.env @@ -0,0 +1,15 @@ +PROJECT_ID=123 +PASSWORD=password +PAY_URL=https://test.paysera.net/pay/ +PAYSERA_PAY_URL=https://test.paysera.net/paysera_pay/ +XML_URL=https://test.paysera.net/xml/ + +PRODUCTION_PUBLIC_KEY=https://test.paysera.net/prod/public_key/ +PRODUCTION_PAYMENT=https://test.paysera.net/prod/payment/ +PRODUCTION_PAYMENT_METHOD_LIST=https://test.paysera.net/prod/paument_method_list/ +PRODUCTION_SMS_ANSWER=https://test.paysera.net/prod/sms_answer/ + +SANDBOX_PUBLIC_KEY=https://test.paysera.net/sandbox/public_key/ +SANDBOX_PAYMENT=https://test.paysera.net/sandbox/payment/ +SANDBOX_PAYMENT_METHOD_LIST=https://test.paysera.net/sandbox/paument_method_list/ +SANDBOX_SMS_ANSWER=https://test.paysera.net/sandbox/sms_answer/ \ No newline at end of file diff --git a/tests/WebToPay/Config/ConfigTest.php b/tests/WebToPay/Config/ConfigTest.php new file mode 100644 index 0000000..bebb5cb --- /dev/null +++ b/tests/WebToPay/Config/ConfigTest.php @@ -0,0 +1,222 @@ +usePutenv(); + $dotenv->load($envFilePath); + } + $config = new WebToPay_Config( + new WebToPay_EnvReader(), + $env, + $customConfig + ); + + $this->assertConfig($expected, $config); + } + + public function configDataProvider(): iterable + { + $envReader = new WebToPay_EnvReader(); + $expectedDefaultRoutes = [ + 'publicKey' => 'https://sandbox.paysera.com/download/public.key', + 'payment' => 'https://sandbox.paysera.com/pay/', + 'paymentMethodList' => 'https://sandbox.paysera.com/new/api/paymentMethods/', + 'smsAnswer' => 'https://sandbox.paysera.com/psms/respond/', + ]; + $env = 'sandbox'; + $routes = new WebToPay_Routes($envReader, $env, $expectedDefaultRoutes); + $customConfig = []; + $expectedConfig = [ + 'getProjectId' => null, + 'getPassword' => null, + 'getPayUrl' => 'https://bank.paysera.com/pay/', + 'getPayseraPayUrl' => 'https://bank.paysera.com/pay/', + 'getXmlUrl' => 'https://www.paysera.com/new/api/paymentMethods/', + 'getRoutes' => $routes, + ]; + + yield 'only default' => [ + $env, + $customConfig, + null, + $expectedConfig, + ]; + + $envFile = dirname(__FILE__) . '/.base.env'; + + $routes = new WebToPay_Routes($envReader, $env, $expectedDefaultRoutes); + + Closure::bind( + function () use ($env, $customConfig) { + $this->publicKey = 'https://test.paysera.net/sandbox/public_key/'; + $this->payment = 'https://test.paysera.net/sandbox/payment/'; + $this->paymentMethodList = 'https://test.paysera.net/sandbox/paument_method_list/'; + $this->smsAnswer = 'https://test.paysera.net/sandbox/sms_answer/'; + }, + $routes, + $routes + )(); + + $expectedConfig = [ + 'getProjectId' => null, + 'getPassword' => null, + 'getPayUrl' => 'https://test.paysera.net/pay/', + 'getPayseraPayUrl' => 'https://test.paysera.net/paysera_pay/', + 'getXmlUrl' => 'https://test.paysera.net/xml/', + 'getRoutes' => $routes, + ]; + + yield 'base' => [ + $env, + $customConfig, + $envFile, + $expectedConfig, + ]; + + $envFile = dirname(__FILE__) . '/.non-full.env'; + + $routes = new WebToPay_Routes($envReader, $env, $expectedDefaultRoutes); + + Closure::bind( + function () use ($env, $customConfig) { + $this->publicKey = 'https://test.paysera.net/sandbox/public_key/'; + $this->payment = 'https://test.paysera.net/sandbox/payment/'; + }, + $routes, + $routes + )(); + + $expectedConfig = [ + 'getProjectId' => null, + 'getPassword' => null, + 'getPayUrl' => 'https://bank.paysera.com/pay/', + 'getPayseraPayUrl' => 'https://test.paysera.net/paysera_pay/', + 'getXmlUrl' => 'https://test.paysera.net/xml/', + 'getRoutes' => $routes, + ]; + + yield 'non-full env' => [ + $env, + $customConfig, + $envFile, + $expectedConfig, + ]; + + $customConfig = [ + 'projectId' => 12347, + 'password' => 'test_password', + 'payUrl' => 'https://custom.paysera.net/pay/', + 'payseraPayUrl' => 'https://custom.paysera.net/paysera_pay/', + 'xmlUrl' => 'https://custom.paysera.net/xml/', + 'routes' => [ + 'publicKey' => 'https://custom.paysera.net/sandbox/public_key/', + 'payment' => 'https://custom.paysera.net/sandbox/payment/', + 'paymentMethodList' => 'https://custom.paysera.net/sandbox/paument_method_list/', + 'smsAnswer' => 'https://custom.paysera.net/sandbox/sms_answer/', + ], + ]; + + $expectedConfig = [ + 'getProjectId' => $customConfig['projectId'], + 'getPassword' => $customConfig['password'], + 'getPayUrl' => $customConfig['payUrl'], + 'getPayseraPayUrl' => $customConfig['payseraPayUrl'], + 'getXmlUrl' => $customConfig['xmlUrl'], + 'getRoutes' => new WebToPay_Routes( + $envReader, + $env, + $expectedDefaultRoutes, + $customConfig['routes'], + ), + ]; + + yield 'custom config' => [ + $env, + $customConfig, + $envFile, + $expectedConfig, + ]; + } + + public function testSwitchEnvironment(): void + { + $envReader = new WebToPay_EnvReader(); + $dotenv = new Dotenv(); + $dotenv->usePutenv(); + $dotenv->load(dirname(__FILE__) . '/.switch-envs.env'); + + $env = 'production'; + $config = new WebToPay_Config($envReader, $env); + + $expected = [ + 'getProjectId' => null, + 'getPassword' => null, + 'getPayUrl' => 'https://test.paysera.net/pay/', + 'getPayseraPayUrl' => 'https://test.paysera.net/paysera_pay/', + 'getXmlUrl' => 'https://test.paysera.net/xml/', + 'getRoutes' => new WebToPay_Routes( + $envReader, + $env, + [ + 'publicKey' => 'https://www.paysera.com/download/public.key', + 'payment' => 'https://bank.paysera.com/pay/', + 'paymentMethodList' => 'https://www.paysera.com/new/api/paymentMethods/', + 'smsAnswer' => 'https://bank.paysera.com/psms/respond/', + ] + ), + ]; + + $this->assertConfig($expected, $config); + + $env = 'sandbox'; + $config->switchEnvironment($env); + + $expected = [ + 'getProjectId' => null, + 'getPassword' => null, + 'getPayUrl' => 'https://test.paysera.net/pay/', + 'getPayseraPayUrl' => 'https://test.paysera.net/paysera_pay/', + 'getXmlUrl' => 'https://test.paysera.net/xml/', + 'getRoutes' => new WebToPay_Routes( + $envReader, + $env, + [ + 'publicKey' => 'https://sandbox.paysera.com/download/public.key', + 'payment' => 'https://sandbox.paysera.com/pay/', + 'paymentMethodList' => 'https://sandbox.paysera.com/new/api/paymentMethods/', + 'smsAnswer' => 'https://sandbox.paysera.com/psms/respond/', + ] + ), + ]; + + $this->assertConfig($expected, $config); + } + + private function assertConfig(array $expectedConfig, WebToPay_Config $config): void + { + foreach ($expectedConfig as $method => $expectedReturnValue) { + $this->assertEquals($expectedReturnValue, $config->{$method}()); + } + } +} diff --git a/tests/WebToPay/Config/EnvReaderTest.php b/tests/WebToPay/Config/EnvReaderTest.php new file mode 100644 index 0000000..070a797 --- /dev/null +++ b/tests/WebToPay/Config/EnvReaderTest.php @@ -0,0 +1,65 @@ +envReader = new WebToPay_EnvReader(); + + $_ENV[self::TEST_VAR_1] = self::TEST_VAR_1_VALUE; + putenv(self::TEST_VAR_2 . '=' . self::TEST_VAR_2_VALUE); + } + + /** + * @dataProvider getAsArrayData + */ + public function testGetAsString(string $key, ?string $expected, ?string $default = null): void + { + $this->assertEquals($expected, $this->envReader->getAsString($key, $default)); + } + + public function getAsArrayData(): iterable + { + + yield 'read from $_ENV' => [ + self::TEST_VAR_1, + self::TEST_VAR_1_VALUE, + ]; + + yield 'read from getenv()' => [ + self::TEST_VAR_2, + self::TEST_VAR_2_VALUE, + ]; + + yield 'with default' => [ + self::TEST_VAR_3, + self::TEST_VAR_3_VALUE, + self::TEST_VAR_3_VALUE, + ]; + + yield 'not exists' => [ + self::TEST_NULL_VAR, + null, + ]; + } + + public function tearDown(): void + { + unset($_ENV[self::TEST_VAR_1]); + putenv(self::TEST_VAR_2 . '='); + } +} diff --git a/tests/WebToPay/Config/RoutesTest.php b/tests/WebToPay/Config/RoutesTest.php new file mode 100644 index 0000000..c762a05 --- /dev/null +++ b/tests/WebToPay/Config/RoutesTest.php @@ -0,0 +1,171 @@ +usePutenv(); + $dotenv->load($envFilePath); + } + + $routesConfig = new WebToPay_Routes( + $envReader, + $env, + $defaults, + $customRoutes, + ); + + foreach ($expected as $method => $expectedReturnValue) { + $this->assertEquals($expectedReturnValue, $routesConfig->{$method}()); + } + } + + public function routesDataProvider(): iterable + { + $envFile = null; + $expected = [ + 'getPublicKeyRoute' => 'https://public-key-test.paysera.net/', + 'getPaymentRoute' => 'https://payment.paysera.net/', + 'getPaymentMethodListRoute' => 'https://payment-method-list.paysera.net/', + 'getSmsAnswerRoute' => 'https://sms-answer.paysera.net/', + ]; + + $env = 'test'; + $defaults = [ + 'publicKey' => $expected['getPublicKeyRoute'], + 'payment' => $expected['getPaymentRoute'], + 'paymentMethodList' => $expected['getPaymentMethodListRoute'], + 'smsAnswer' => $expected['getSmsAnswerRoute'], + ]; + $customRoutes = []; + + yield 'only default vars' => [ + $env, + $envFile, + $defaults, + $customRoutes, + $expected, + ]; + + $expected = [ + 'getPublicKeyRoute' => 'https://custom-public-key-test.paysera.net/', + 'getPaymentRoute' => 'https://custom-payment.paysera.net/', + 'getPaymentMethodListRoute' => 'https://custom-payment-method-list.paysera.net/', + 'getSmsAnswerRoute' => 'https://custom-sms-answer.paysera.net/', + ]; + + $customRoutes = [ + 'publicKey' => $expected['getPublicKeyRoute'], + 'payment' => $expected['getPaymentRoute'], + 'paymentMethodList' => $expected['getPaymentMethodListRoute'], + 'smsAnswer' => $expected['getSmsAnswerRoute'], + ]; + + yield 'only custom vars' => [ + $env, + $envFile, + $defaults, + $customRoutes, + $expected, + ]; + + $expected = [ + 'getPublicKeyRoute' => 'https://public-key-test.paysera.net/', + 'getPaymentRoute' => 'https://payment.paysera.net/', + 'getPaymentMethodListRoute' => 'https://payment-method-list.paysera.net/', + 'getSmsAnswerRoute' => 'https://sms-answer.paysera.net/', + ]; + $customRoutes = []; + + $envFile = dirname(__FILE__) . '/.routes-test.env'; + + yield 'only env vars' => [ + $env, + $envFile, + $defaults, + $customRoutes, + $expected, + ]; + + $expected = [ + 'getPublicKeyRoute' => 'https://custom-public-key-test.paysera.net/', + 'getPaymentRoute' => 'https://custom-payment.paysera.net/', + 'getPaymentMethodListRoute' => 'https://custom-payment-method-list.paysera.net/', + 'getSmsAnswerRoute' => 'https://custom-sms-answer.paysera.net/', + ]; + + $customRoutes = [ + 'publicKey' => $expected['getPublicKeyRoute'], + 'payment' => $expected['getPaymentRoute'], + 'paymentMethodList' => $expected['getPaymentMethodListRoute'], + 'smsAnswer' => $expected['getSmsAnswerRoute'], + ]; + + yield 'customs on isset env vars' => [ + $env, + $envFile, + $defaults, + $customRoutes, + $expected, + ]; + + $expected = [ + 'getPublicKeyRoute' => 'https://public-key-test.paysera.net/', + 'getPaymentRoute' => 'https://payment.paysera.net/', + 'getPaymentMethodListRoute' => 'https://default-payment-method-list.paysera.net/', + 'getSmsAnswerRoute' => 'https://default-sms-answer.paysera.net/', + ]; + $customRoutes = []; + + $defaults = [ + 'paymentMethodList' => $expected['getPaymentMethodListRoute'], + 'smsAnswer' => $expected['getSmsAnswerRoute'], + ]; + + $envFile = dirname(__FILE__) . '/.non-full-routes-test.env'; + + yield 'default on env var is not set' => [ + $env, + $envFile, + $defaults, + $customRoutes, + $expected, + ]; + + $expected['getSmsAnswerRoute'] = 'https://custom-sms-answer.paysera.net/'; + $customRoutes = [ + 'smsAnswer' => $expected['getSmsAnswerRoute'], + ]; + + yield 'customs, env and defaults' => [ + $env, + $envFile, + $defaults, + $customRoutes, + $expected, + ]; + } +} diff --git a/tests/WebToPay/FactoryTest.php b/tests/WebToPay/FactoryTest.php index 295c3bd..049a29c 100644 --- a/tests/WebToPay/FactoryTest.php +++ b/tests/WebToPay/FactoryTest.php @@ -21,11 +21,11 @@ class WebToPay_FactoryTest extends TestCase public function setUp(): void { $this->factory = new WebToPay_Factory([ - 'projectId' => '123', + 'projectId' => 123, 'password' => 'abc', ]); $this->factoryWithoutPasswordInConfiguration = new WebToPay_Factory([ - 'projectId' => '123', + 'projectId' => 123, ]); $this->factoryWithoutProjectIdInConfiguration = new WebToPay_Factory([ 'password' => 'abc', diff --git a/tests/WebToPayTest.php b/tests/WebToPayTest.php index 0d0233f..fff7a4e 100644 --- a/tests/WebToPayTest.php +++ b/tests/WebToPayTest.php @@ -51,7 +51,7 @@ public function testBuildRepeatRequestWithoutProjectId() public function testGetPaymentUrl() { $url = WebToPay::getPaymentUrl('LIT'); - $this->assertEquals($url, WebToPay::PAY_URL); + $this->assertEquals($url, 'https://bank.paysera.com/pay/'); $url = WebToPay::getPaymentUrl('ENG'); $this->assertEquals($url, 'https://bank.paysera.com/pay/'); }