PayPal / PHP · 2023年4月3日 0

PHP进行PAYPAL支付开发

CSDN有点恶心了,不登录不给复制,还每次都弹窗
准备:需要有一个商家账户,测试环境可以使用PayPal的沙盒账户进行测试,网址: https://developer.paypal.com/

这里我没有使用PayPal提供的SDK,而是自己封装了一个类。

PayPal Payment v1的API已经被v2替代,所以这里只使用v2接口创建订单。各个接口的参数请参照PayPal官方文档。地址:https://developer.paypal.com/docs/api/overview/

<?php
declare (strict_types=1);

namespace PayPal;
use \Exception;

/**
 * Class PP
 * PayPal相关API,所有接口失败都会抛出异常
 * @uses 实例化:$pp = new PayPal(string $clientId, string $clientSecret, array $config);
 * @uses 添加发货信息:$pp->addTrackers(array $trackers): bool
 * @uses 查询发货信息:$pp->getTracker(string $transactionId, string $trackingNumber): array
 * @uses 执行收款:$pp->executePayment(string $paymentId, string $payerId): array
 * @uses 执行退款:$pp->executeRefund(string $transactionId, float $amount, string $currency = 'USD', string $reason = ''): array
 * @uses 创建PayPal订单v2:$pp->createOrder(array $params): array
 * @uses 执行收款v2:$pp->captureOrder(string $payPalOrderId): array
 * @uses 获取订单详情v2:$pp->getOrder(string $payPalOrderId): array
 * @author Lushaoming<lusm@sz-bcs.com.cn>
 * @date 2020-03-12
 */
class PayPal
{
    private $clientId;
    private $clientSecret;
    private $config;
    private $cipher;
    private $error;
    private static $expiryBufferTime = 120;

    const SDK_NAME = 'PayPal-PHP-SDK';
    const SDK_VERSION = '1.14.0';

    /**
     * PP constructor.
     * @param string $clientId
     * @param string $clientSecret
     * @param array $config See default configurations in $this->_getDefaultConfig()
     */
    public function __construct($clientId, $clientSecret, $config = [])
    {
        $this->clientId = $clientId;
        $this->clientSecret = $clientSecret;
        $this->config = $this->_mergeConfig($config);
        $this->cipher = new Cipher($clientSecret);
    }

    /**
     * Get Access Token.
     * If caching is enabled, first check whether the cached token has expired.
     * If it does not expire, use it directly. If it expires, obtain the token again
     * @return string
     * @throws \Exception
     */
    private function _getAccessToken()
    {
        if ($this->config['cache.enabled'] && file_exists($this->config['cache.FileName'])) {
            $data = file_get_contents($this->config['cache.FileName']);
            if ($data) {
                $data = json_decode($data, true);
                if (isset($data[$this->clientId]) &&
                    $data[$this->clientId]['accessTokenEncrypted'] &&
                    ($data[$this->clientId]['tokenCreateTime'] + $data[$this->clientId]['tokenExpiresIn'] - self::$expiryBufferTime > time() ))
                {
                    return $this->cipher->decrypt($data[$this->clientId]['accessTokenEncrypted']);
                }
            }
        }

        // 重新获取token
        return $this->_updateAccessToken();
    }

    /**
     * Add tracking information to PayPal
     * @param array $trackers
     * @return bool
     * @throws Exception
     */
    public function addTrackers(array $trackers)
    {
        $items = [];
        foreach ($trackers as $tracker) {
            $items[] = [
                'transaction_id' => $tracker['transaction_id'],
                'tracking_number' => $tracker['tracking_number'],
                'status' => $tracker['status'],
                'carrier' => $tracker['carrier'],
                'carrier_name_other' => $tracker['carrier_name_other'],
                'shipment_date' => $tracker['shipment_date']
            ];
        }
        if (count($items) == 0) {
            $this->setError('No item.');
            return false;
        }

        $param = [
            'trackers' => $items
        ];

        $this->_execute(
            $this->_getApiDomain() . '/v1/shipping/trackers-batch',
            'POST',
            $this->_toJSON($param),
            $this->_dealHeaders($this->_getRequestHeaders())
        );
        return true;
    }

    /**
     * @param string $transactionId 交易号
     * @param string $trackingNumber 运输号
     * @throws \Exception
     * @return array
     */
    public function getTracker($transactionId, $trackingNumber)
    {
        $url = $this->_getApiDomain() . "/v1/shipping/trackers/{$transactionId}-{$trackingNumber}";
        $headers = $this->_getRequestHeaders();
        $result = $this->_execute($url, 'GET', "", $this->_dealHeaders($headers));
        return $this->_toArray($result);
    }

    /**
     * @param string $paymentId
     * @param string $payerId
     * @return array|mixed
     * @throws \Exception
     */
    public function executePayment($paymentId, $payerId)
    {
        $param = ['payer_id' => $payerId];
        $result = $this->_execute(
            $this->_getApiDomain() . "/v1/payments/payment/{$paymentId}/execute",
            'POST',
            $this->_toJSON($param),
            $this->_dealHeaders($this->_getRequestHeaders())
        );
        return $this->_toArray($result);
    }

    /**
     * @param string $transactionId
     * @param float $amount
     * @param string $currency
     * @param string $reason
     * @return array|mixed
     * @throws Exception
     */
    public function executeRefund($transactionId, $amount, $currency = 'USD', $reason = '')
    {
        $param = [
            'amount' => [
                'currency' => $currency,
                'total' => $amount
            ],
            'reason' => $reason
        ];
        $result = $this->_execute(
            $this->_getApiDomain() . "/v1/payments/sale/$transactionId/refund",
            'POST',
            $this->_toJSON($param),
            $this->_dealHeaders($this->_getRequestHeaders())
        );
        return $this->_toArray($result);
    }

    /**
     *
     * @param array $params
     * @return array|mixed
     * @throws Exception
     */
    public function createOrder(array $params)
    {
        $result = $this->_execute(
            $this->_getApiDomain()."/v2/checkout/orders",
            'POST',
            json_encode($params),
            $this->_dealHeaders($this->_getRequestHeaders())
        );
        return $this->_toArray($result);
    }

    /**
     * @param $payPalOrderId
     * @return array|mixed
     * @throws Exception
     */
    public function getOrder($payPalOrderId)
    {
        $result = $this->_execute(
            $this->_getApiDomain()."/v2/checkout/orders/{$payPalOrderId}",
            'GET',
            '',
            $this->_dealHeaders($this->_getRequestHeaders())
        );
        return $this->_toArray($result);
    }

    /**
     * @param string $payPalOrderId Approval paypal order id
     * @return array|mixed
     * @throws Exception
     */
    public function captureOrder($payPalOrderId)
    {
        $result = $this->_execute(
            $this->_getApiDomain()."/v2/checkout/orders/{$payPalOrderId}/capture",
            'POST',
            '',
            $this->_dealHeaders($this->_getRequestHeaders())
        );
        return $this->_toArray($result);
    }

    /**
     * Get request headers
     * @return array
     * @throws \Exception
     */
    private function _getRequestHeaders()
    {
        return [
            'Content-Type' => 'application/json',
            'Authorization' => 'Bearer ' . $this->_getAccessToken()
        ];
    }

    /**
     * Get a new access token
     * @return string
     * @throws \Exception
     */
    private function _updateAccessToken()
    {
        $headers = array(
            "User-Agent"    => $this->_getUserAgent(self::SDK_NAME, self::SDK_VERSION),
            "Authorization" => "Basic " . base64_encode($this->clientId . ":" . $this->clientSecret),
            "Accept"        => "*/*",
        );

        $params = [
            'grant_type' => 'client_credentials'
        ];

        $result = $this->_execute($this->_getOauthUrl(), 'POST', http_build_query($params), $this->_dealHeaders($headers));
        $result = json_decode($result, true);

        // 写入文件缓存
        if ($this->config['cache.enabled']) {

            // 注意不能覆盖其他账号的缓存
            $tokens = [];
            if (file_exists($this->config['cache.FileName'])) {
                $data = file_get_contents($this->config['cache.FileName']);
                if ($data) {
                    $tokens = $this->_toArray($data);
                }
            }

            $tokens[$this->clientId] = [
                'clientId' => $this->clientId,
                'accessTokenEncrypted' => $this->cipher->encrypt($result['access_token']),// token加密存储
                'tokenCreateTime' => time(),
                'tokenExpiresIn' => $result['expires_in']
            ];

            if (!file_put_contents($this->config['cache.FileName'], json_encode($tokens))) {
                throw new \Exception("Failed to write cache, path: " . $this->config['cache.FileName']);
            };
        }

        return $result['access_token'];
    }

    /**
     * Array or object to json
     * @param $data
     * @param int $options
     * @return mixed|string
     */
    private function _toJSON($data, $options = 0)
    {
        // Because of PHP Version 5.3, we cannot use JSON_UNESCAPED_SLASHES option
        // Instead we would use the str_replace command for now.
        // TODO: Replace this code with return json_encode($data, $options | 64); once we support PHP >= 5.4
        if (version_compare(phpversion(), '5.4.0', '>=') === true) {
            return json_encode($data, $options | 64);
        }
        return str_replace('\\/', '/', json_encode($data, $options));
    }

    /**
     * Json to array
     * @param $data
     * @return array|mixed
     */
    private function _toArray($data)
    {
        if (!$data) return [];
        return json_decode($data, true);
    }

    /**
     * 证书下载地址:http://curl.haxx.se/ca/cacert.pem
     * @param $url
     * @param $method
     * @param $data
     * @param array $headers
     * @param array $options
     * @return mixed
     * @throws \Exception
     */
    private function _execute($url, $method, $data, $headers = [], $options = [])
    {
        $ch = curl_init($url);

        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_HEADER, false);
        curl_setopt($ch, CURLINFO_HEADER_OUT, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        // 不直接输出
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        //Determine Curl Options based on Method
        switch ($method) {
            case 'POST':
                curl_setopt($ch, CURLOPT_POST, true);
                curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
                break;
            case 'PUT':
            case 'PATCH':
            case 'DELETE':
                curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
                break;
        }

//        curl_setopt($ch, CURLOPT_HEADERFUNCTION, array($this, 'parseResponseHeaders'));

        if (strpos($this->_getApiDomain(), "https://") === 0) {
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
        }

//        if ($caCertPath = $this->getCACertFilePath()) {
//            curl_setopt($ch, CURLOPT_CAINFO, $caCertPath);
//        }

        //Execute Curl Request
        $result = curl_exec($ch);
        //Retrieve Response Status
        $httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        //Retry if Certificate Exception
        if (curl_errno($ch) == 60) {
            $this->_writeLog("Invalid or no certificate authority found - Retrying using bundled CA certs file");
            curl_setopt($ch, CURLOPT_CAINFO, $this->getCACertFilePath());
            $result = curl_exec($ch);
            //Retrieve Response Status
            $httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        }

        //Throw Exception if Retries and Certificates doenst work
        if (curl_errno($ch)) {
            $ex = new \Exception(
                curl_error($ch),
                curl_errno($ch)
            );
            curl_close($ch);
            throw $ex;
        }

        // Get Request and Response Headers
//        $requestHeaders = curl_getinfo($ch, CURLINFO_HEADER_OUT);

        //Close the curl request
        curl_close($ch);


        //More Exceptions based on HttpStatus Code
        if ($httpStatus < 200 || $httpStatus >= 300) {
            $ex = new \Exception(
                $result,
                $httpStatus
            );
            $this->_writeLog("Got Http response code $httpStatus when accessing {$url}. " . $result, 'ERROR');
            throw $ex;
        }

        return $result;
    }

    private function getCACertFilePath()
    {
        return __DIR__.'/../certs/paypal-cacert.pem';
    }

    /**
     * Get oauth token credential URL
     * @return string
     */
    private function _getOauthUrl()
    {
        return $this->_getApiDomain() . "/v1/oauth2/token";
    }

    /**
     * Get API domain based on $this->config['mode']
     * @return string
     */
    private function _getApiDomain()
    {
        if ($this->config['mode'] == 'production') return 'https://api.paypal.com';
        else return 'https://api.sandbox.paypal.com';
    }

    /**
     * Get user agent
     * @param $sdkName
     * @param $sdkVersion
     * @return string
     */
    private static function _getUserAgent($sdkName, $sdkVersion)
    {
        $featureList = array(
            'platform-ver=' . PHP_VERSION,
            'bit=' . self::_getPHPBit(),
            'os=' . str_replace(' ', '_', php_uname('s') . ' ' . php_uname('r')),
            'machine=' . php_uname('m')
        );
        if (defined('OPENSSL_VERSION_TEXT')) {
            $opensslVersion = explode(' ', OPENSSL_VERSION_TEXT);
            $featureList[] = 'crypto-lib-ver=' . $opensslVersion[1];
        }
        if (function_exists('curl_version')) {
            $curlVersion = curl_version();
            $featureList[] = 'curl=' . $curlVersion['version'];
        }
        return sprintf("PayPalSDK/%s %s (%s)", $sdkName, $sdkVersion, implode('; ', $featureList));
    }

    private static function _getPHPBit()
    {
        switch (PHP_INT_SIZE) {
            case 4:
                return '32';
            case 8:
                return '64';
            default:
                return PHP_INT_SIZE;
        }
    }

    /**
     * Deal request headers
     * @param array $headers
     * @return array
     */
    private function _dealHeaders($headers)
    {
        $ret = [];
        foreach ($headers as $k => $v) {
            $ret[] = "$k: $v";
        }
        return $ret;
    }

    /**
     * Merge from default configs and return.
     * @param array $config
     * @return array
     */
    private function _mergeConfig($config)
    {
        $defaultConfigs = $this->_getDefaultConfig();
        foreach ($defaultConfigs as $key => $value) {
            if (isset($config[$key])) $defaultConfigs[$key] = $config[$key];
        }
        return $defaultConfigs;
    }

    /**
     * Get Default configs
     * @return array
     */
    private function _getDefaultConfig()
    {
        return [
            'mode' => 'live', // sandbox / live
            'log.LogEnabled' => 1, // 1 / 0
            'log.FileName' => '/www/log/PayPal.log',
            'log.LogLevel' => 'INFO', // Sandbox Environments: DEBUG, INFO, WARN, ERROR; Live Environments: INFO, WARN, ERROR
            'validation.level' => 'disable',
            'cache.enabled' => 1, // 1 / 0
            'cache.FileName' => '/www/log/auth.cache'
        ];
    }

    private function setError($error)
    {
        $this->error = $error;
    }

    public function getError()
    {
        return $this->error;
    }

    /**
     * Write PayPal log
     * @param $log
     * @param string $level
     */
    private function _writeLog($log, $level = 'INFO')
    {
        if ($this->config['log.LogEnabled']) {
            $message = "[".date('Y-m-d H:i:s') . "][{$level}-".$this->_generateLogId()."]{$log}";
            if (!file_put_contents($this->config['log.FileName'], $message . PHP_EOL, FILE_APPEND)) {
//                throw new \Exception("Failed to write log, path: " . $this->config['log.FileName']);
            };
        }
    }

    /**
     * @return string
     */
    private function _generateLogId()
    {
        static $logId;
        if (!$logId) {
            $logId = mb_substr(md5(time() . mt_rand(100, 999)), 0, 6);
        }
        return $logId;
    }

    /**
     * Print the debug message
     * @param $msg
     */
    public static function printMsg($msg)
    {
        if (is_array($msg)) {
            echo '['.date('Y-m-d H:i:s') . ']' . json_encode($msg) . PHP_EOL;
        } else {
            echo '['.date('Y-m-d H:i:s') . ']' . $msg . PHP_EOL;
        }
    }
}
  • 加密解密类
<?php

namespace Reolink\Payments\PayPal;

/**
 * Class Cipher
 *
 * Helper class to encrypt/decrypt data with secret key
 *
 * @package Reolink\Payments\PayPal
 */
class Cipher
{
    private $secretKey;

    /**
     * Fixed IV Size
     */
    const IV_SIZE = 16;

    public function __construct($secretKey)
    {
        $this->secretKey = $secretKey;
    }

    /**
     * Encrypts the input text using the cipher key
     *
     * @param $input
     * @return string
     */
    public function encrypt($input)
    {
        // Create a random IV. Not using mcrypt to generate one, as to not have a dependency on it.
        $iv = substr(uniqid("", true), 0, Cipher::IV_SIZE);
        // Encrypt the data
        $encrypted = openssl_encrypt($input, "AES-256-CBC", $this->secretKey, 0, $iv);
        // Encode the data with IV as prefix
        return base64_encode($iv . $encrypted);
    }

    /**
     * Decrypts the input text from the cipher key
     *
     * @param $input
     * @return string
     */
    public function decrypt($input)
    {
        // Decode the IV + data
        $input = base64_decode($input);
        // Remove the IV
        $iv = substr($input, 0, Cipher::IV_SIZE);
        // Return Decrypted Data
        return openssl_decrypt(substr($input, Cipher::IV_SIZE), "AES-256-CBC", $this->secretKey, 0, $iv);
    }
}