PayPlus.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. <?php
  2. namespace App\Services;
  3. use App\Util;
  4. use Exception;
  5. class PayPlus
  6. {
  7. const PAYIN_CONFIG = 'PayPlus';
  8. const PAYOUT_CONFIG = 'PayPlusOut';
  9. protected $config;
  10. public function __construct(array $config = null)
  11. {
  12. if ($config === null) {
  13. $config = (new PayConfig())->getConfig(self::PAYIN_CONFIG) ?: [];
  14. }
  15. $this->config = $config;
  16. }
  17. public function getConfig()
  18. {
  19. return $this->config;
  20. }
  21. public function getPayoutService()
  22. {
  23. return new self((new PayConfig())->getConfig(self::PAYOUT_CONFIG) ?: []);
  24. }
  25. public function signPayoutPayload(array $payload)
  26. {
  27. return hash('sha256', $this->buildPayoutSignString($payload) . ($this->config['appKey'] ?? ''));
  28. }
  29. public function verifyPayoutSignature(array $payload, $signature)
  30. {
  31. return hash_equals($this->signPayoutPayload($payload), strtolower((string) $signature));
  32. }
  33. public function buildPayoutSignString(array $payload)
  34. {
  35. $flat = $this->flattenPayload($payload);
  36. ksort($flat);
  37. $items = [];
  38. foreach ($flat as $key => $value) {
  39. if ($value === null || (is_string($value) && trim($value) === '')) {
  40. continue;
  41. }
  42. if (is_bool($value)) {
  43. $value = $value ? 'true' : 'false';
  44. }
  45. $items[] = $key . '=' . $value;
  46. }
  47. return implode('&', $items);
  48. }
  49. public function flattenPayload(array $payload, $prefix = '')
  50. {
  51. $result = [];
  52. foreach ($payload as $key => $value) {
  53. if ($key === 'Authorization' || $key === 'sign') {
  54. continue;
  55. }
  56. $flatKey = $prefix === '' ? $key : $prefix . '_' . $key;
  57. if (is_array($value) && $this->isAssoc($value)) {
  58. $result += $this->flattenPayload($value, $flatKey);
  59. continue;
  60. }
  61. if (is_array($value)) {
  62. $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  63. }
  64. $result[$flatKey] = $value;
  65. }
  66. return $result;
  67. }
  68. public function encryptComponentDelta($payload, $aesKey, $iv)
  69. {
  70. $tag = '';
  71. $cipherText = openssl_encrypt(
  72. $payload,
  73. 'aes-256-gcm',
  74. hex2bin($aesKey),
  75. OPENSSL_RAW_DATA,
  76. hex2bin($iv),
  77. $tag,
  78. '',
  79. 16
  80. );
  81. if ($cipherText === false) {
  82. throw new Exception('PayPlus AES encrypt failed.');
  83. }
  84. return base64_encode(hex2bin($iv) . $cipherText . $tag);
  85. }
  86. public function decryptComponentDelta($componentDelta, $aesKey, $iv)
  87. {
  88. $raw = base64_decode($componentDelta, true);
  89. if ($raw === false || strlen($raw) <= 28) {
  90. throw new Exception('PayPlus AES response is invalid.');
  91. }
  92. $cipherTextWithTag = substr($raw, 12);
  93. $tag = substr($cipherTextWithTag, -16);
  94. $cipherText = substr($cipherTextWithTag, 0, -16);
  95. $plainText = openssl_decrypt(
  96. $cipherText,
  97. 'aes-256-gcm',
  98. hex2bin($aesKey),
  99. OPENSSL_RAW_DATA,
  100. hex2bin($iv),
  101. $tag
  102. );
  103. if ($plainText === false) {
  104. throw new Exception('PayPlus AES decrypt failed.');
  105. }
  106. return $plainText;
  107. }
  108. public function encryptPayinPayload(array $payload)
  109. {
  110. $aesKey = bin2hex(random_bytes(32));
  111. $iv = bin2hex(random_bytes(12));
  112. return [
  113. 'aes_key' => $aesKey,
  114. 'iv' => $iv,
  115. 'body' => [
  116. 'componentX' => $this->rsaEncrypt($aesKey),
  117. 'componentY' => $this->rsaEncrypt($iv),
  118. 'componentDelta' => $this->encryptComponentDelta(
  119. json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
  120. $aesKey,
  121. $iv
  122. ),
  123. ],
  124. ];
  125. }
  126. public function decryptPayinResponse(array $response, $aesKey, $iv)
  127. {
  128. $componentDelta = $response['data']['componentDelta'] ?? '';
  129. if ($componentDelta === '') {
  130. return [];
  131. }
  132. return json_decode($this->decryptComponentDelta($componentDelta, $aesKey, $iv), true) ?: [];
  133. }
  134. public function postPayin(array $payload)
  135. {
  136. return $this->postPayinPath('/up-apis/merchant/payment', $payload);
  137. }
  138. public function queryPayinOrder($platformOrderId, $orderId = '')
  139. {
  140. if ($platformOrderId) {
  141. $payload = [
  142. 'platform_order_id' => (string) $platformOrderId,
  143. ];
  144. } elseif ($orderId) {
  145. $payload = [
  146. 'order_id' => (string) $orderId,
  147. ];
  148. } else {
  149. throw new Exception('Either platformOrderId or orderId must be provided for querying PayPlus order.');
  150. }
  151. Util::WriteLog('PayPlus', 'Query PayPlus order: ' . json_encode($payload));
  152. return $this->postPayinPath(
  153. $this->config['query_path'] ?? '/up-apis/merchant/payment/detail',
  154. $payload
  155. );
  156. }
  157. protected function postPayinPath($path, array $payload)
  158. {
  159. $encrypted = $this->encryptPayinPayload($payload);
  160. $response = $this->curlJson(
  161. rtrim($this->config['apiUrl'] ?? '', '/') . $path,
  162. $encrypted['body'],
  163. $this->buildPayinHeaders()
  164. );
  165. $decoded = json_decode($response, true) ?: [];
  166. Util::WriteLog('PayPlus', 'PayPlus raw response: ' . json_encode($decoded));
  167. $componentDelta = $this->decryptPayinResponse($decoded, $encrypted['aes_key'], $encrypted['iv']);
  168. if (!empty($componentDelta)) {
  169. Util::WriteLog('PayPlus', 'PayPlus decrypted componentDelta: ' . json_encode($componentDelta));
  170. }
  171. $decoded['decryptedComponentDelta'] = $componentDelta;
  172. return $decoded;
  173. }
  174. public function queryPayoutOrder($transactionId = '', $referenceId = '')
  175. {
  176. $payload = [];
  177. if ($transactionId !== '') {
  178. $payload['transaction_id'] = (string) $transactionId;
  179. }
  180. if ($referenceId !== '') {
  181. $payload['reference_id'] = (string) $referenceId;
  182. }
  183. if (empty($payload)) {
  184. throw new Exception('Either transactionId or referenceId must be provided for querying PayPlus payout.');
  185. }
  186. return $this->postPayout(
  187. $this->config['payout_query_path'] ?? '/rest/v2/payouts/detail',
  188. $payload
  189. );
  190. }
  191. public function postPayout(string $path, array $payload)
  192. {
  193. $payload['timestamp'] = $payload['timestamp'] ?? $this->milliseconds();
  194. $headers = [
  195. 'Content-Type: application/json',
  196. 'AppId: ' . ($this->config['appId'] ?? ''),
  197. 'Authorization: ' . $this->signPayoutPayload($payload),
  198. ];
  199. Util::WriteLog('PayPlus', 'PayPlus request(' . $path . '): ' . json_encode($payload) . ' | Headers: ' . json_encode($headers));
  200. $response = $this->curlJson(rtrim($this->config['apiUrl'] ?? '', '/') . $path, $payload, $headers);
  201. Util::WriteLog('PayPlus', 'PayPlus response(' . $path . '): ' . json_encode($response));
  202. return json_decode($response, true) ?: [];
  203. }
  204. public function buildBeneficiaryPayload(array $user, $timestamp = null)
  205. {
  206. $userId = (string) ($user['user_id'] ?? $user['payee_id'] ?? 0);
  207. $name = trim((string) ($user['name'] ?? $user['user_name'] ?? ''));
  208. $names = preg_split('/\s+/', $name, 2);
  209. return [
  210. 'payee_id' => $userId,
  211. 'language' => $this->stringOrDefault($user['language'] ?? null, 'en'),
  212. 'country' => strtoupper($this->stringOrDefault($user['country'] ?? null, 'US')),
  213. 'phone' => $this->normalizePhone($user['phone'] ?? ''),
  214. 'birthdate' => $this->stringOrDefault($user['birthdate'] ?? null, '1970-01-01'),
  215. 'email' => $this->emailOrDefault($user['email'] ?? '', $userId),
  216. 'first_name' => $this->englishNameOrDefault($user['first_name'] ?? ($names[0] ?? null), 'unknown'),
  217. 'last_name' => $this->englishNameOrDefault($user['last_name'] ?? ($names[1] ?? null), 'user'),
  218. 'zip' => $this->stringOrDefault($user['zip'] ?? null, '00000'),
  219. 'city' => $this->stringOrDefault($user['city'] ?? null, 'unknown'),
  220. 'state' => $this->stringOrDefault($user['state'] ?? null, 'NA'),
  221. 'address' => $this->stringOrDefault($user['address'] ?? null, 'unknown'),
  222. 'timestamp' => $timestamp ?? $this->milliseconds(),
  223. ];
  224. }
  225. protected function rsaEncrypt($value)
  226. {
  227. $publicKey = $this->config['publicKey'] ?? '';
  228. if ($publicKey === '') {
  229. throw new Exception('PayPlus public key is empty.');
  230. }
  231. if (!class_exists('\phpseclib\Crypt\RSA')) {
  232. throw new Exception('phpseclib is required for PayPlus RSA OAEP SHA-512.');
  233. }
  234. $rsa = new \phpseclib\Crypt\RSA();
  235. $rsa->setEncryptionMode(\phpseclib\Crypt\RSA::ENCRYPTION_OAEP);
  236. $rsa->setHash('sha512');
  237. $rsa->setMGFHash('sha512');
  238. $rsa->loadKey($this->normalizePublicKey($publicKey));
  239. $encrypted = $rsa->encrypt($value);
  240. if ($encrypted === false) {
  241. throw new Exception('PayPlus RSA encrypt failed.');
  242. }
  243. return base64_encode($encrypted);
  244. }
  245. protected function buildPayinHeaders()
  246. {
  247. return [
  248. 'Content-Type: application/json',
  249. 'x-api-key: ' . ($this->config['apiKey'] ?? ''),
  250. 'x-client-id: ' . ($this->config['clientId'] ?? ''),
  251. 'x-app-id: ' . ($this->config['appId'] ?? ''),
  252. ];
  253. }
  254. protected function curlJson($url, array $payload, array $headers)
  255. {
  256. $data = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  257. $ch = curl_init();
  258. curl_setopt($ch, CURLOPT_URL, $url);
  259. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  260. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  261. curl_setopt($ch, CURLOPT_POST, true);
  262. curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
  263. curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20);
  264. curl_setopt($ch, CURLOPT_TIMEOUT, 20);
  265. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  266. curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  267. $result = curl_exec($ch);
  268. $curl_errno = curl_errno($ch);
  269. if ($curl_errno) {
  270. $error = curl_error($ch);
  271. curl_close($ch);
  272. Util::WriteLog('PayPlus_error', 'CURL Error: ' . "$curl_errno" . $error);
  273. throw new Exception($error);
  274. }
  275. curl_close($ch);
  276. return $result;
  277. }
  278. protected function normalizePublicKey($key)
  279. {
  280. if (strpos($key, 'BEGIN PUBLIC KEY') !== false) {
  281. return $key;
  282. }
  283. return "-----BEGIN PUBLIC KEY-----\n"
  284. . chunk_split($key, 64, "\n")
  285. . "-----END PUBLIC KEY-----";
  286. }
  287. protected function normalizePhone($phone)
  288. {
  289. $phone = trim((string) $phone);
  290. if ($phone === '') {
  291. return '+1-0000000000';
  292. }
  293. if (strpos($phone, '+') === 0) {
  294. return $phone;
  295. }
  296. return '+1-' . preg_replace('/\D+/', '', $phone);
  297. }
  298. protected function emailOrDefault($email, $userId)
  299. {
  300. $email = trim((string) $email);
  301. if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
  302. return $email;
  303. }
  304. return 'unknown' . $userId . '@example.com';
  305. }
  306. protected function stringOrDefault($value, $default)
  307. {
  308. $value = trim((string) $value);
  309. return $value === '' ? $default : $value;
  310. }
  311. protected function englishNameOrDefault($value, $default)
  312. {
  313. $value = preg_replace('/[^A-Za-z]+/', '', (string) $value);
  314. return $this->stringOrDefault($value, $default);
  315. }
  316. protected function milliseconds()
  317. {
  318. return (int) round(microtime(true) * 1000);
  319. }
  320. private function isAssoc(array $value)
  321. {
  322. return array_keys($value) !== range(0, count($value) - 1);
  323. }
  324. }