| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424 |
- <?php
- namespace App\Services;
- use App\Util;
- use Exception;
- class PayPlus
- {
- const PAYIN_CONFIG = 'PayPlus';
- const PAYOUT_CONFIG = 'PayPlusOut';
- protected $config;
- public function __construct(array $config = null)
- {
- if ($config === null) {
- $config = (new PayConfig())->getConfig(self::PAYIN_CONFIG) ?: [];
- }
- $this->config = $config;
- }
- public function getConfig()
- {
- return $this->config;
- }
- public function getPayoutService()
- {
- return new self((new PayConfig())->getConfig(self::PAYOUT_CONFIG) ?: []);
- }
- public function signPayoutPayload(array $payload)
- {
- return hash('sha256', $this->buildPayoutSignString($payload) . ($this->config['appKey'] ?? ''));
- }
- public function verifyPayoutSignature(array $payload, $signature)
- {
- $sign = hash(
- 'sha256',
- $this->buildPayoutSignString($this->formatPayoutNotifyNumbers($payload)) . ($this->config['appKey'] ?? '')
- );
- return hash_equals($sign, strtolower((string) $signature));
- }
- public function buildPayoutSignString(array $payload)
- {
- $flat = $this->flattenPayload($payload);
- ksort($flat);
- $items = [];
- foreach ($flat as $key => $value) {
- if ($value === null || (is_string($value) && trim($value) === '')) {
- continue;
- }
- if (is_bool($value)) {
- $value = $value ? 'true' : 'false';
- }
- $items[] = $key . '=' . $value;
- }
- return implode('&', $items);
- }
- public function flattenPayload(array $payload, $prefix = '')
- {
- $result = [];
- foreach ($payload as $key => $value) {
- if ($key === 'Authorization' || $key === 'sign') {
- continue;
- }
- $flatKey = $prefix === '' ? $key : $prefix . '_' . $key;
- if (is_array($value) && $this->isAssoc($value)) {
- $result += $this->flattenPayload($value, $flatKey);
- continue;
- }
- if (is_array($value)) {
- $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- }
- $result[$flatKey] = $value;
- }
- return $result;
- }
- public function encryptComponentDelta($payload, $aesKey, $iv)
- {
- $tag = '';
- $cipherText = openssl_encrypt(
- $payload,
- 'aes-256-gcm',
- hex2bin($aesKey),
- OPENSSL_RAW_DATA,
- hex2bin($iv),
- $tag,
- '',
- 16
- );
- if ($cipherText === false) {
- throw new Exception('PayPlus AES encrypt failed.');
- }
- return base64_encode(hex2bin($iv) . $cipherText . $tag);
- }
- public function decryptComponentDelta($componentDelta, $aesKey, $iv)
- {
- $raw = base64_decode($componentDelta, true);
- if ($raw === false || strlen($raw) <= 28) {
- throw new Exception('PayPlus AES response is invalid.');
- }
- $cipherTextWithTag = substr($raw, 12);
- $tag = substr($cipherTextWithTag, -16);
- $cipherText = substr($cipherTextWithTag, 0, -16);
- $plainText = openssl_decrypt(
- $cipherText,
- 'aes-256-gcm',
- hex2bin($aesKey),
- OPENSSL_RAW_DATA,
- hex2bin($iv),
- $tag
- );
- if ($plainText === false) {
- throw new Exception('PayPlus AES decrypt failed.');
- }
- return $plainText;
- }
- public function encryptPayinPayload(array $payload)
- {
- $aesKey = bin2hex(random_bytes(32));
- $iv = bin2hex(random_bytes(12));
- return [
- 'aes_key' => $aesKey,
- 'iv' => $iv,
- 'body' => [
- 'componentX' => $this->rsaEncrypt($aesKey),
- 'componentY' => $this->rsaEncrypt($iv),
- 'componentDelta' => $this->encryptComponentDelta(
- json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
- $aesKey,
- $iv
- ),
- ],
- ];
- }
- public function decryptPayinResponse(array $response, $aesKey, $iv)
- {
- $componentDelta = $response['data']['componentDelta'] ?? '';
- if ($componentDelta === '') {
- return [];
- }
- return json_decode($this->decryptComponentDelta($componentDelta, $aesKey, $iv), true) ?: [];
- }
- public function postPayin(array $payload)
- {
- return $this->postPayinPath('/up-apis/merchant/payment', $payload);
- }
- public function queryPayinOrder($platformOrderId, $orderId = '')
- {
- if ($platformOrderId) {
- $payload = [
- 'platform_order_id' => (string) $platformOrderId,
- ];
- } elseif ($orderId) {
- $payload = [
- 'order_id' => (string) $orderId,
- ];
- } else {
- throw new Exception('Either platformOrderId or orderId must be provided for querying PayPlus order.');
- }
- Util::WriteLog('PayPlus', 'Query PayPlus order: ' . json_encode($payload));
- return $this->postPayinPath(
- $this->config['query_path'] ?? '/up-apis/merchant/payment/detail',
- $payload
- );
- }
- protected function postPayinPath($path, array $payload)
- {
- $encrypted = $this->encryptPayinPayload($payload);
- $response = $this->curlJson(
- rtrim($this->config['apiUrl'] ?? '', '/') . $path,
- $encrypted['body'],
- $this->buildPayinHeaders()
- );
- $decoded = json_decode($response, true) ?: [];
- Util::WriteLog('PayPlus', 'PayPlus raw response: ' . json_encode($decoded));
- $componentDelta = $this->decryptPayinResponse($decoded, $encrypted['aes_key'], $encrypted['iv']);
- if (!empty($componentDelta)) {
- Util::WriteLog('PayPlus', 'PayPlus decrypted componentDelta: ' . json_encode($componentDelta));
- }
- $decoded['decryptedComponentDelta'] = $componentDelta;
- return $decoded;
- }
- public function queryPayoutOrder($transactionId = '', $referenceId = '')
- {
- $payload = [];
- if ($transactionId !== '') {
- $payload['transaction_id'] = (string) $transactionId;
- }
- if ($referenceId !== '') {
- $payload['reference_id'] = (string) $referenceId;
- }
- if (empty($payload)) {
- throw new Exception('Either transactionId or referenceId must be provided for querying PayPlus payout.');
- }
- return $this->postPayout(
- $this->config['payout_query_path'] ?? '/rest/v2/payouts/detail',
- $payload
- );
- }
- public function postPayout(string $path, array $payload)
- {
- $payload['timestamp'] = $payload['timestamp'] ?? $this->milliseconds();
- $headers = [
- 'Content-Type: application/json',
- 'AppId: ' . ($this->config['appId'] ?? ''),
- 'Authorization: ' . $this->signPayoutPayload($payload),
- ];
- Util::WriteLog('PayPlus', 'PayPlus request(' . $path . '): ' . json_encode($payload) . ' | Headers: ' . json_encode($headers));
- $response = $this->curlJson(rtrim($this->config['apiUrl'] ?? '', '/') . $path, $payload, $headers);
- Util::WriteLog('PayPlus', 'PayPlus response(' . $path . '): ' . json_encode($response));
- return json_decode($response, true) ?: [];
- }
- public function buildBeneficiaryPayload(array $user, $timestamp = null)
- {
- $userId = (string) ($user['user_id'] ?? $user['payee_id'] ?? 0);
- $name = trim((string) ($user['name'] ?? $user['user_name'] ?? ''));
- $names = preg_split('/\s+/', $name, 2);
- return [
- 'payee_id' => $userId,
- 'language' => $this->stringOrDefault($user['language'] ?? null, 'en'),
- 'country' => strtoupper($this->stringOrDefault($user['country'] ?? null, 'US')),
- 'phone' => $this->normalizePhone($user['phone'] ?? ''),
- 'birthdate' => $this->stringOrDefault($user['birthdate'] ?? null, '1970-01-01'),
- 'email' => $this->emailOrDefault($user['email'] ?? '', $userId),
- 'first_name' => $this->englishNameOrDefault($user['first_name'] ?? ($names[0] ?? null), 'unknown'),
- 'last_name' => $this->englishNameOrDefault($user['last_name'] ?? ($names[1] ?? null), 'user'),
- 'zip' => $this->stringOrDefault($user['zip'] ?? null, '00000'),
- 'city' => $this->stringOrDefault($user['city'] ?? null, 'unknown'),
- 'state' => $this->stringOrDefault($user['state'] ?? null, 'NA'),
- 'address' => $this->stringOrDefault($user['address'] ?? null, 'unknown'),
- 'timestamp' => $timestamp ?? $this->milliseconds(),
- ];
- }
- protected function rsaEncrypt($value)
- {
- $publicKey = $this->config['publicKey'] ?? '';
- if ($publicKey === '') {
- throw new Exception('PayPlus public key is empty.');
- }
- if (!class_exists('\phpseclib\Crypt\RSA')) {
- throw new Exception('phpseclib is required for PayPlus RSA OAEP SHA-512.');
- }
- $rsa = new \phpseclib\Crypt\RSA();
- $rsa->setEncryptionMode(\phpseclib\Crypt\RSA::ENCRYPTION_OAEP);
- $rsa->setHash('sha512');
- $rsa->setMGFHash('sha512');
- $rsa->loadKey($this->normalizePublicKey($publicKey));
- $encrypted = $rsa->encrypt($value);
- if ($encrypted === false) {
- throw new Exception('PayPlus RSA encrypt failed.');
- }
- return base64_encode($encrypted);
- }
- protected function buildPayinHeaders()
- {
- return [
- 'Content-Type: application/json',
- 'x-api-key: ' . ($this->config['apiKey'] ?? ''),
- 'x-client-id: ' . ($this->config['clientId'] ?? ''),
- 'x-app-id: ' . ($this->config['appId'] ?? ''),
- ];
- }
- protected function curlJson($url, array $payload, array $headers)
- {
- $data = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- $ch = curl_init();
- curl_setopt($ch, CURLOPT_URL, $url);
- curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
- curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
- curl_setopt($ch, CURLOPT_POST, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
- curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20);
- curl_setopt($ch, CURLOPT_TIMEOUT, 20);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
- $result = curl_exec($ch);
- $curl_errno = curl_errno($ch);
- if ($curl_errno) {
- $error = curl_error($ch);
- curl_close($ch);
- Util::WriteLog('PayPlus_error', 'CURL Error: ' . "$curl_errno" . $error);
- throw new Exception($error);
- }
- curl_close($ch);
- return $result;
- }
- protected function normalizePublicKey($key)
- {
- if (strpos($key, 'BEGIN PUBLIC KEY') !== false) {
- return $key;
- }
- return "-----BEGIN PUBLIC KEY-----\n"
- . chunk_split($key, 64, "\n")
- . "-----END PUBLIC KEY-----";
- }
- protected function normalizePhone($phone)
- {
- $phone = trim((string) $phone);
- if ($phone === '') {
- return '+1-0000000000';
- }
- if (strpos($phone, '+') === 0) {
- return $phone;
- }
- return '+1-' . preg_replace('/\D+/', '', $phone);
- }
- protected function emailOrDefault($email, $userId)
- {
- $email = trim((string) $email);
- if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
- return $email;
- }
- return 'unknown' . $userId . '@example.com';
- }
- protected function stringOrDefault($value, $default)
- {
- $value = trim((string) $value);
- return $value === '' ? $default : $value;
- }
- protected function englishNameOrDefault($value, $default)
- {
- $value = preg_replace('/[^A-Za-z]+/', '', (string) $value);
- return $this->stringOrDefault($value, $default);
- }
- protected function formatPayoutNotifyNumbers(array $payload)
- {
- foreach ($payload as $key => $value) {
- if (is_array($value)) {
- $payload[$key] = $this->formatPayoutNotifyNumbers($value);
- continue;
- }
- if ($this->isPayoutNotifyAmountKey($key) && is_numeric($value)) {
- $payload[$key] = number_format((float) $value, 4, '.', '');
- }
- }
- return $payload;
- }
- protected function isPayoutNotifyAmountKey($key)
- {
- return in_array($key, [
- 'amount',
- 'payout_amount',
- 'source_amount',
- 'payout_fee',
- 'fee',
- ], true);
- }
- protected function milliseconds()
- {
- return (int) round(microtime(true) * 1000);
- }
- private function isAssoc(array $value)
- {
- return array_keys($value) !== range(0, count($value) - 1);
- }
- }
|