. * * @package WebToPay * @author EVP International * @license http://www.gnu.org/licenses/lgpl.html * @version 3.0.1 * @link http://www.webtopay.com/ */ /** * Contains static methods for most used scenarios. */ class WebToPay { /** * WebToPay Library version. */ public const VERSION = '3.0.1'; /** * Server URL where all requests should go. */ public const PAY_URL = 'https://bank.paysera.com/pay/'; /** * Server URL where all non-lithuanian language requests should go. */ public const PAYSERA_PAY_URL = 'https://bank.paysera.com/pay/'; /** * Server URL where we can get XML with payment method data. */ public const XML_URL = 'https://www.paysera.com/new/api/paymentMethods/'; /** * SMS answer url. * * @deprecated */ public const SMS_ANSWER_URL = 'https://bank.paysera.com/psms/respond/'; /** * Builds request data array. * * This method checks all given data and generates correct request data * array or raises WebToPayException on failure. * * Possible keys: * https://developers.paysera.com/en/checkout/integrations/integration-specification * * @param array $data Information about current payment request * * @return array * * @throws WebToPayException on data validation error */ public static function buildRequest(array $data): array { self::checkRequiredParameters($data); $password = $data['sign_password']; $projectId = $data['projectid']; unset($data['sign_password']); unset($data['projectid']); $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); $requestBuilder = $factory->getRequestBuilder(); return $requestBuilder->buildRequest($data); } /** * Builds request and redirects user to payment window with generated request data * * 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 * * @throws WebToPayException on data validation error */ public static function redirectToPayment(array $data, bool $exit = false): void { self::checkRequiredParameters($data); $password = $data['sign_password']; $projectId = $data['projectid']; unset($data['sign_password']); unset($data['projectid']); $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); $url = $factory->getRequestBuilder() ->buildRequestUrlFromData($data); if (WebToPay_Functions::headers_sent()) { echo ''; } else { header("Location: $url", true); } printf( 'Redirecting to %s. Please wait.', htmlentities($url, ENT_QUOTES, 'UTF-8'), htmlentities($url, ENT_QUOTES, 'UTF-8') ); if ($exit) { // @codeCoverageIgnoreStart exit(); // @codeCoverageIgnoreEnd } } /** * Builds repeat request data array. * * This method checks all given data and generates correct request data * array or raises WebToPayException on failure. * * Method accepts single parameter $data of array type. All possible array * keys are described here: * https://developers.paysera.com/en/checkout/integrations/integration-specification * * @param array $data Information about current payment request * * @return array * * @throws WebToPayException on data validation error */ public static function buildRepeatRequest(array $data): array { if (!isset($data['sign_password']) || !isset($data['projectid']) || !isset($data['orderid'])) { throw new WebToPayException('sign_password, projectid or orderid is not provided'); } $password = $data['sign_password']; $projectId = $data['projectid']; $orderId = $data['orderid']; $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); $requestBuilder = $factory->getRequestBuilder(); return $requestBuilder->buildRepeatRequest($orderId); } /** * Returns payment url. Argument is same as lang parameter in request data * * @param string $language * @return string $url */ public static function getPaymentUrl(string $language = 'LIT'): string { return (in_array($language, ['lt', 'lit', 'LIT'], true)) ? self::PAY_URL : self::PAYSERA_PAY_URL; } /** * Parses request (query) data and validates its signature. * * @param array $query usually $_GET * @param int|null $projectId * @param string|null $password * * @return array * * @throws WebToPayException * @throws WebToPay_Exception_Callback * @throws WebToPay_Exception_Configuration */ public static function validateAndParseData(array $query, ?int $projectId, ?string $password): array { $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]); $validator = $factory->getCallbackValidator(); return $validator->validateAndParseData($query); } /** * Sends SMS answer * * @param array $userData * * @throws WebToPayException * @throws WebToPay_Exception_Validation * * @deprecated * @codeCoverageIgnore */ public static function smsAnswer(array $userData): void { if (!isset($userData['id']) || !isset($userData['msg']) || !isset($userData['sign_password'])) { throw new WebToPay_Exception_Validation('id, msg and sign_password are required'); } $smsId = $userData['id']; $text = $userData['msg']; $password = $userData['sign_password']; $logFile = $userData['log'] ?? null; try { $factory = new WebToPay_Factory(['password' => $password]); $factory->getSmsAnswerSender()->sendAnswer($smsId, $text); if ($logFile) { self::log('OK', 'SMS ANSWER ' . $smsId . ' ' . $text, $logFile); } } catch (WebToPayException $e) { if ($logFile) { self::log('ERR', 'SMS ANSWER ' . $e, $logFile); } throw $e; } } /** * Gets available payment methods for project. Gets methods min and max amounts in specified currency. * * @throws WebToPayException * @throws WebToPay_Exception_Configuration */ public static function getPaymentMethodList( int $projectId, ?float $amount, ?string $currency = 'EUR' ): WebToPay_PaymentMethodList { $factory = new WebToPay_Factory(['projectId' => $projectId]); return $factory->getPaymentMethodListProvider()->getPaymentMethodList($amount, $currency); } /** * Logs to file. Just skips logging if file is not writeable * * @deprecated * @codeCoverageIgnore */ protected static function log(string $type, string $msg, string $logfile): void { $fp = @fopen($logfile, 'a'); if (!$fp) { return; } $logline = [ $type, $_SERVER['REMOTE_ADDR'] ?? '-', date('[Y-m-d H:i:s O]'), 'v' . self::VERSION . ':', $msg, ]; $logline = implode(' ', $logline)."\n"; fwrite($fp, $logline); fclose($fp); // clear big log file if (filesize($logfile) > 1024 * 1024 * pi()) { copy($logfile, $logfile.'.old'); unlink($logfile); } } /** * @param array $data * * @throws WebToPayException */ protected static function checkRequiredParameters(array $data): void { if (!isset($data['sign_password']) || !isset($data['projectid'])) { throw new WebToPayException('sign_password or projectid is not provided'); } } } /** * Base exception class for all exceptions in this library */ class WebToPayException extends Exception { /** * Missing field. */ public const E_MISSING = 1; /** * Invalid field value. */ public const E_INVALID = 2; /** * Max length exceeded. */ public const E_MAXLEN = 3; /** * Regexp for field value doesn't match. */ public const E_REGEXP = 4; /** * Missing or invalid user given parameters. */ public const E_USER_PARAMS = 5; /** * Logging errors */ public const E_LOG = 6; /** * SMS answer errors */ public const E_SMS_ANSWER = 7; /** * Macro answer errors */ public const E_STATUS = 8; /** * Library errors - if this happens, bug-report should be sent; also you can check for newer version */ public const E_LIBRARY = 9; /** * Errors in remote service - it returns some invalid data */ public const E_SERVICE = 10; /** * Deprecated usage errors */ public const E_DEPRECATED_USAGE = 11; protected ?string $fieldName = null; /** * Sets field which failed */ public function setField(?string $fieldName): void { $this->fieldName = $fieldName; } /** * Gets field which failed */ public function getField(): ?string { return $this->fieldName; } } /** * Class to hold information about payment method */ class WebToPay_PaymentMethod { /** * Assigned key for this payment method */ protected string $key; protected ?int $minAmount; protected ?int $maxAmount; protected ?string $currency; /** * Logo url list by language. Usually logo is same for all languages, but exceptions exist * * @var array */ protected array $logoList; /** * Title list by language * * @var array */ protected array $titleTranslations; /** * Default language to use for titles */ protected string $defaultLanguage; protected bool $isIban; protected ?string $baseCurrency; /** * Constructs object * * @param string $key * @param integer|null $minAmount * @param integer|null $maxAmount * @param string|null $currency * @param array $logoList * @param array $titleTranslations * @param string $defaultLanguage * @param bool $isIban * @param string|null $baseCurrency */ public function __construct( string $key, ?int $minAmount, ?int $maxAmount, ?string $currency, array $logoList = [], array $titleTranslations = [], string $defaultLanguage = 'lt', bool $isIban = false, ?string $baseCurrency = null ) { $this->key = $key; $this->minAmount = $minAmount; $this->maxAmount = $maxAmount; $this->currency = $currency; $this->logoList = $logoList; $this->titleTranslations = $titleTranslations; $this->setDefaultLanguage($defaultLanguage); $this->setIsIban($isIban); $this->setBaseCurrency($baseCurrency); } /** * Sets default language for titles. * Returns itself for fluent interface */ public function setDefaultLanguage(string $language): WebToPay_PaymentMethod { $this->defaultLanguage = $language; return $this; } /** * Gets default language for titles */ public function getDefaultLanguage(): string { return $this->defaultLanguage; } /** * Get assigned payment method key */ public function getKey(): string { return $this->key; } /** * Gets logo url for this payment method. Uses specified language or default one. * If logotype is not found for specified language, null is returned. */ public function getLogoUrl(?string $languageCode = null): ?string { if ($languageCode !== null && isset($this->logoList[$languageCode])) { return $this->logoList[$languageCode]; } elseif (isset($this->logoList[$this->defaultLanguage])) { return $this->logoList[$this->defaultLanguage]; } else { return null; } } /** * Gets title for this payment method. Uses specified language or default one. */ public function getTitle(?string $languageCode = null): string { 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->key; } } /** * Checks if this payment method can be used for specified amount. * Throws exception if currency checked is not the one, for which payment method list was downloaded. * * @throws WebToPayException */ public function isAvailableForAmount(int $amount, string $currency): bool { if ($this->currency !== $currency) { throw new WebToPayException( 'Currencies does not match. You have to get payment types for the currency you are checking. Given currency: ' . $currency . ', available currency: ' . $this->currency ); } return ( ($this->minAmount === null || $amount >= $this->minAmount) && ($this->maxAmount === null || $amount <= $this->maxAmount) ); } /** * Returns min amount for this payment method. If no min amount is specified, returns empty string. */ public function getMinAmountAsString(): string { return $this->minAmount === null ? '' : ($this->minAmount . ' ' . $this->currency); } /** * Returns max amount for this payment method. If no max amount is specified, returns empty string. */ public function getMaxAmountAsString(): string { return $this->maxAmount === null ? '' : ($this->maxAmount . ' ' . $this->currency); } /** * Set if this method returns IBAN number after payment */ public function setIsIban(bool $isIban): void { $this->isIban = $isIban; } /** * Get if this method returns IBAN number after payment */ public function isIban(): bool { return $this->isIban; } /** * Setter of BaseCurrency */ public function setBaseCurrency(?string $baseCurrency): void { $this->baseCurrency = $baseCurrency; } /** * Getter of BaseCurrency */ public function getBaseCurrency(): ?string { return $this->baseCurrency; } } /** * 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; } } /** * 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); } } } /** * Raised on error in callback */ class WebToPay_Exception_Callback extends WebToPayException { } /** * Raised if configuration is incorrect */ 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 */ class WebToPay_Functions { public static function function_exists(string $functionName): bool { return \function_exists($functionName); } public static function headers_sent(): bool { return \headers_sent(); } } /** * Payment method configuration for some country */ class WebToPay_PaymentMethodCountry { protected string $countryCode; /** * Holds available payment types for this country * * @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. * * @var array */ protected array $titleTranslations; /** * Constructs object * * @param string $countryCode * @param array $titleTranslations * @param string $defaultLanguage */ public function __construct(string $countryCode, array $titleTranslations, string $defaultLanguage = 'lt') { $this->countryCode = $countryCode; $this->defaultLanguage = $defaultLanguage; $this->titleTranslations = $titleTranslations; $this->groups = []; } /** * Sets default language for titles. * Returns itself for fluent interface */ public function setDefaultLanguage(string $language): WebToPay_PaymentMethodCountry { $this->defaultLanguage = $language; foreach ($this->groups as $group) { $group->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. */ public function getTitle(?string $languageCode = null): string { 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 default language for titles */ public function getDefaultLanguage(): string { return $this->defaultLanguage; } /** * Gets country code */ public function getCode(): string { return $this->countryCode; } /** * 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 addGroup(WebToPay_PaymentMethodGroup $group): WebToPay_PaymentMethodGroup { return $this->groups[$group->getKey()] = $group; } /** * Gets group object with specified group key. If no group with such key is found, returns null. */ public function getGroup(string $groupKey): ?WebToPay_PaymentMethodGroup { return $this->groups[$groupKey] ?? null; } /** * Returns payment method groups registered for this country. * * @return WebToPay_PaymentMethodGroup[] */ public function getGroups(): array { return $this->groups; } /** * Gets payment methods in all groups * * @return WebToPay_PaymentMethod[] */ public function getPaymentMethods(): array { $paymentMethods = []; foreach ($this->groups as $group) { $paymentMethods = array_merge($paymentMethods, $group->getPaymentMethods()); } return $paymentMethods; } /** * Returns new country instance with only those payment methods, which are available for provided amount. */ public function filterForAmount(int $amount, string $currency): WebToPay_PaymentMethodCountry { $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); } } return $country; } /** * Returns new country instance with only those payment methods, which are returns or not iban number after payment */ public function filterForIban(bool $isIban = true): WebToPay_PaymentMethodCountry { $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; } /** * Returns whether this country has no groups */ public function isEmpty(): bool { return count($this->groups) === 0; } /** * Loads groups from given XML node */ public function fromXmlNode(SimpleXMLElement $countryNode): void { 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); } } /** * Method to create new group instances. Overwrite if you have to use some other group subtype. * * @param string $groupKey * @param array $translations * * @return WebToPay_PaymentMethodGroup */ protected function createGroup(string $groupKey, array $translations = []): WebToPay_PaymentMethodGroup { return new WebToPay_PaymentMethodGroup($groupKey, $translations, $this->defaultLanguage); } } /** * Builds and signs requests */ class WebToPay_RequestBuilder { 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; /** * Constructs object */ 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 { $this->validateRequest($data); $data['version'] = WebToPay::VERSION; $data['projectid'] = $this->projectId; unset($data['repeat_request']); return $this->createRequest($data); } /** * Builds the full request url (including the protocol and the domain) * * @param array $data * @return string * @throws WebToPayException */ public function buildRequestUrlFromData(array $data): string { $request = $this->buildRequest($data); return $this->urlBuilder->buildForRequest($request); } /** * 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 buildRepeatRequest(int $orderId): array { $data['orderid'] = $orderId; $data['version'] = WebToPay::VERSION; $data['projectid'] = $this->projectId; $data['repeat_request'] = '1'; return $this->createRequest($data); } /** * 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); } /** * Checks data to be valid by passed specification * * @param array $data * * @throws WebToPay_Exception_Validation */ protected function validateRequest(array $data): void { 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 ($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), ]; } } /** * 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 * * @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; } } /** * Sends answer to SMS payment if it was not provided with response to callback */ class WebToPay_SmsAnswerSender { protected string $password; protected WebToPay_WebClient $webClient; protected WebToPay_UrlBuilder $urlBuilder; /** * Constructs object */ public function __construct(string $password, WebToPay_WebClient $webClient, WebToPay_UrlBuilder $urlBuilder) { $this->password = $password; $this->webClient = $webClient; $this->urlBuilder = $urlBuilder; } /** * Sends answer by sms ID get from callback. Answer can be sent only if it was not provided * when responding to the callback * * @throws WebToPayException * * @codeCoverageIgnore */ public function sendAnswer(int $smsId, string $text): void { $content = $this->webClient->get($this->urlBuilder->buildForSmsAnswer(), [ 'id' => $smsId, 'msg' => $text, 'transaction' => md5($this->password . '|' . $smsId), ]); if (strpos($content, 'OK') !== 0) { throw new WebToPayException( sprintf('Error: %s', $content), WebToPayException::E_SMS_ANSWER ); } } } /** * 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(int $projectId, string $currency, string $defaultLanguage = 'lt', ?int $amount = null) { $this->projectId = $projectId; $this->countries = []; $this->defaultLanguage = $defaultLanguage; $this->currency = $currency; $this->amount = $amount; } /** * Sets default language for titles. * Returns itself for fluent interface */ public function setDefaultLanguage(string $language): WebToPay_PaymentMethodList { $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; } /** * 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 { $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; } } /** * Loads countries from given XML node */ public function fromXmlNode(SimpleXMLElement $xmlNode): void { 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); } } /** * 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); } } /** * 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 */ public function __construct(string $projectPassword) { $this->projectPassword = $projectPassword; } /** * 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'); } return md5($request['data'] . $this->projectPassword) === $request['ss1']; } } /** * Checks SS2 signature. Depends on SSL functions */ class WebToPay_Sign_SS2SignChecker implements WebToPay_Sign_SignCheckerInterface { protected string $publicKey; protected WebToPay_Util $util; /** * Constructs object */ public function __construct(string $publicKey, WebToPay_Util $util) { $this->publicKey = $publicKey; $this->util = $util; } /** * Checks signature * * @param array $request * * @return bool * * @throws WebToPay_Exception_Callback */ public function checkSign(array $request): bool { if (!isset($request['data']) || !isset($request['ss2'])) { throw new WebToPay_Exception_Callback('Not enough parameters in callback. Possible version mismatch'); } $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 * * @param array $request * * @return boolean */ public function checkSign(array $request): bool; } /** * 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; /** * 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 */ public function getPaymentMethodList(?float $amount, ?string $currency): WebToPay_PaymentMethodList { 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]; } } /** * Creates objects. Also caches to avoid creating several instances of same objects */ class WebToPay_Factory { public const ENV_PRODUCTION = 'production'; public const ENV_SANDBOX = 'sandbox'; /** * @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/', ], ], ]; protected string $environment; /** * @var array */ protected array $configuration; protected ?WebToPay_WebClient $webClient = null; 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. * 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(array $configuration = []) { $this->configuration = array_merge(self::$defaultConfiguration, $configuration); $this->environment = self::ENV_PRODUCTION; } /** * If passed true the factory will use sandbox when constructing URLs */ public function useSandbox(bool $enableSandbox): self { if ($enableSandbox) { $this->environment = self::ENV_SANDBOX; } else { $this->environment = self::ENV_PRODUCTION; } return $this; } /** * Creates or gets callback validator instance * * @throws WebToPayException * @throws WebToPay_Exception_Configuration */ 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'); } $this->callbackValidator = new WebToPay_CallbackValidator( (int) $this->configuration['projectId'], $this->getSigner(), $this->getUtil(), $this->configuration['password'] ?? null ); } return $this->callbackValidator; } /** * Creates or gets request builder instance * * @throws WebToPay_Exception_Configuration */ public function getRequestBuilder(): WebToPay_RequestBuilder { 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; } 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; } /** * Creates or gets SMS answer sender instance * * @throws WebToPay_Exception_Configuration */ public function getSmsAnswerSender(): WebToPay_SmsAnswerSender { 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->smsAnswerSender; } /** * Creates or gets payment list provider instance * * @throws WebToPayException * @throws WebToPay_Exception_Configuration */ public function getPaymentMethodListProvider(): WebToPay_PaymentMethodListProvider { 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->paymentMethodListProvider; } /** * 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 { 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']); } } return $this->signer; } /** * Creates or gets web client instance */ protected function getWebClient(): WebToPay_WebClient { if ($this->webClient === null) { $this->webClient = new WebToPay_WebClient(); } return $this->webClient; } /** * Creates or gets util instance * * @throws WebToPay_Exception_Configuration */ protected function getUtil(): WebToPay_Util { if ($this->util === null) { $this->util = new WebToPay_Util(); } return $this->util; } } /** * Used to build a complete request URL. * * Class WebToPay_UrlBuilder */ class WebToPay_UrlBuilder { public const PLACEHOLDER_KEY = '[domain]'; /** * @var array */ protected array $configuration; protected string $environment; /** * @var array */ protected array $environmentSettings; /** * @param array $configuration * @param string $environment */ public function __construct(array $configuration, string $environment) { $this->configuration = $configuration; $this->environment = $environment; $this->environmentSettings = $this->configuration['routes'][$this->environment]; } public function getEnvironment(): string { return $this->environment; } /** * Builds a complete request URL based on the provided parameters * * @param array $request * * @return string */ public function buildForRequest(array $request): string { return $this->createUrlFromRequestAndLanguage($request); } /** * Builds a complete URL for payment list API */ public function buildForPaymentsMethodList(int $projectId, ?string $amount, ?string $currency): string { $route = $this->environmentSettings['paymentMethodList']; return $route . $projectId . '/currency:' . $currency . '/amount:' . $amount; } /** * Builds a complete URL for Sms Answer * * @codeCoverageIgnore */ public function buildForSmsAnswer(): string { return $this->environmentSettings['smsAnswer']; } /** * Build the URL to the public key */ public function buildForPublicKey(): string { return $this->environmentSettings['publicKey']; } /** * Creates a URL from the request and data provided. * * @param array $request * * @return string */ protected function createUrlFromRequestAndLanguage(array $request): string { $url = $this->getPaymentUrl() . '?' . http_build_query($request, '', '&'); return preg_replace('/[\r\n]+/is', '', $url) ?? ''; } /** * Returns payment URL. Argument is same as lang parameter in request data * * @return string */ public function getPaymentUrl(): string { return $this->environmentSettings['payment']; } } /** * Wrapper class to group payment methods. Each country can have several payment method groups, each of them * have one or more payment methods. */ class WebToPay_PaymentMethodGroup { /** * Some unique (in the scope of country) key for this group */ protected string $groupKey; /** * Translations array for this group. Holds associative array of group title by country codes. * * @var array */ protected array $translations; /** * Holds actual payment methods * * @var WebToPay_PaymentMethod[] */ protected array $paymentMethods; /** * Default language for titles */ protected string $defaultLanguage; /** * Constructs object * * @param string $groupKey * @param array $translations * @param string $defaultLanguage */ public function __construct(string $groupKey, array $translations = [], string $defaultLanguage = 'lt') { $this->groupKey = $groupKey; $this->translations = $translations; $this->defaultLanguage = $defaultLanguage; $this->paymentMethods = []; } /** * Sets default language for titles. * Returns itself for fluent interface */ public function setDefaultLanguage(string $language): WebToPay_PaymentMethodGroup { $this->defaultLanguage = $language; foreach ($this->paymentMethods as $paymentMethod) { $paymentMethod->setDefaultLanguage($language); } return $this; } /** * Gets default language for titles */ public function getDefaultLanguage(): string { return $this->defaultLanguage; } /** * 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 getTitle(?string $languageCode = null): string { if ($languageCode !== null && isset($this->translations[$languageCode])) { return $this->translations[$languageCode]; } elseif (isset($this->translations[$this->defaultLanguage])) { return $this->translations[$this->defaultLanguage]; } else { return $this->groupKey; } } /** * Returns group key */ public function getKey(): string { return $this->groupKey; } /** * Returns available payment methods for this group * * @return WebToPay_PaymentMethod[] */ public function getPaymentMethods(): array { return $this->paymentMethods; } /** * Adds new payment method for this group. * If some other payment method with specified key was registered earlier, overwrites it. * Returns given payment method * * @param WebToPay_PaymentMethod $paymentMethod * * @return WebToPay_PaymentMethod */ public function addPaymentMethod(WebToPay_PaymentMethod $paymentMethod): WebToPay_PaymentMethod { return $this->paymentMethods[$paymentMethod->getKey()] = $paymentMethod; } /** * Gets payment method object with key. If no payment method with such key is found, returns null. */ public function getPaymentMethod(string $key): ?WebToPay_PaymentMethod { return $this->paymentMethods[$key] ?? null; } /** * Returns new group instance with only those payment methods, which are available for provided amount. * * @throws WebToPayException */ public function filterForAmount(int $amount, string $currency): WebToPay_PaymentMethodGroup { $group = new WebToPay_PaymentMethodGroup($this->groupKey, $this->translations, $this->defaultLanguage); foreach ($this->getPaymentMethods() as $paymentMethod) { if ($paymentMethod->isAvailableForAmount($amount, $currency)) { $group->addPaymentMethod($paymentMethod); } } return $group; } /** * Returns new country instance with only those payment methods, which are returns or not iban number after payment */ public function filterForIban(bool $isIban = true): WebToPay_PaymentMethodGroup { $group = new WebToPay_PaymentMethodGroup($this->groupKey, $this->translations, $this->defaultLanguage); foreach ($this->getPaymentMethods() as $paymentMethod) { if ($paymentMethod->isIban() == $isIban) { $group->addPaymentMethod($paymentMethod); } } return $group; } /** * Returns whether this group has no payment methods * * @return bool */ public function isEmpty(): bool { return count($this->paymentMethods) === 0; } /** * Loads payment methods from given XML node */ public function fromXmlNode(SimpleXMLElement $groupNode): void { foreach ($groupNode->payment_type as $paymentTypeNode) { $key = (string)$paymentTypeNode->attributes()->key; $titleTranslations = []; foreach ($paymentTypeNode->title as $titleNode) { $titleTranslations[(string)$titleNode->attributes()->language] = (string)$titleNode; } $logoTranslations = []; foreach ($paymentTypeNode->logo_url as $logoNode) { if ((string)$logoNode !== '') { $logoTranslations[(string)$logoNode->attributes()->language] = (string)$logoNode; } } $minAmount = null; $maxAmount = null; $currency = null; $isIban = false; $baseCurrency = null; if (isset($paymentTypeNode->min)) { $minAmount = (int)$paymentTypeNode->min->attributes()->amount; $currency = (string)$paymentTypeNode->min->attributes()->currency; } if (isset($paymentTypeNode->max)) { $maxAmount = (int)$paymentTypeNode->max->attributes()->amount; $currency = (string)$paymentTypeNode->max->attributes()->currency; } if (isset($paymentTypeNode->is_iban)) { /* * There are ONLY two ways to fetch value from a node of the SimpleXMLElement class: * - use the `current` function: current($paymentTypeNode->is_iban); * - implicitly use the `__toString()` magic method casting the node to a string. * We chose the 2nd one * * FYI: the expression `(bool) $paymentTypeNode->is_iban` ALWAYS returns `true` */ $isIban = (string) $paymentTypeNode->is_iban === "1"; } if (isset($paymentTypeNode->base_currency)) { $baseCurrency = (string)$paymentTypeNode->base_currency; } $this->addPaymentMethod($this->createPaymentMethod( $key, $minAmount, $maxAmount, $currency, $logoTranslations, $titleTranslations, $isIban, $baseCurrency )); } } /** * Method to create new payment method instances. Overwrite if you have to use some other subclass. * * @param string $key * @param int|null $minAmount * @param int|null $maxAmount * @param string|null $currency * @param array $logoList * @param array $titleTranslations * @param bool $isIban * @param mixed $baseCurrency * * @return WebToPay_PaymentMethod */ protected function createPaymentMethod( string $key, ?int $minAmount, ?int $maxAmount, ?string $currency, array $logoList = [], array $titleTranslations = [], bool $isIban = false, $baseCurrency = null ): WebToPay_PaymentMethod { return new WebToPay_PaymentMethod( $key, $minAmount, $maxAmount, $currency, $logoList, $titleTranslations, $this->defaultLanguage, $isIban, $baseCurrency ); } } /** * 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 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) ); } } } }