PayPlus.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  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. $payload = [
  141. 'platform_order_id' => (string) $platformOrderId,
  142. ];
  143. if ($orderId !== '') {
  144. $payload['order_id'] = (string) $orderId;
  145. }
  146. return $this->postPayinPath(
  147. $this->config['query_path'] ?? '/up-apis/merchant/payment/query',
  148. $payload
  149. );
  150. }
  151. protected function postPayinPath($path, array $payload)
  152. {
  153. $encrypted = $this->encryptPayinPayload($payload);
  154. $response = $this->curlJson(
  155. rtrim($this->config['apiUrl'] ?? '', '/') . $path,
  156. $encrypted['body'],
  157. $this->buildPayinHeaders()
  158. );
  159. $decoded = json_decode($response, true) ?: [];
  160. Util::WriteLog('PayPlus', 'PayPlus raw response: ' . json_encode($decoded));
  161. $componentDelta = $this->decryptPayinResponse($decoded, $encrypted['aes_key'], $encrypted['iv']);
  162. if (!empty($componentDelta)) {
  163. Util::WriteLog('PayPlus', 'PayPlus decrypted componentDelta: ' . json_encode($componentDelta));
  164. }
  165. $decoded['decryptedComponentDelta'] = $componentDelta;
  166. return $decoded;
  167. }
  168. public function postPayout(string $path, array $payload)
  169. {
  170. $payload['timestamp'] = $payload['timestamp'] ?? $this->milliseconds();
  171. $headers = [
  172. 'Content-Type: application/json',
  173. 'AppId: ' . ($this->config['appId'] ?? ''),
  174. 'Authorization: ' . $this->signPayoutPayload($payload),
  175. ];
  176. $response = $this->curlJson(rtrim($this->config['apiUrl'] ?? '', '/') . $path, $payload, $headers);
  177. return json_decode($response, true) ?: [];
  178. }
  179. public function buildBeneficiaryPayload(array $user, $timestamp = null)
  180. {
  181. $userId = (string) ($user['user_id'] ?? $user['payee_id'] ?? 0);
  182. $name = trim((string) ($user['name'] ?? $user['user_name'] ?? ''));
  183. $names = preg_split('/\s+/', $name, 2);
  184. return [
  185. 'payee_id' => $userId,
  186. 'language' => $this->stringOrDefault($user['language'] ?? null, 'en'),
  187. 'country' => strtoupper($this->stringOrDefault($user['country'] ?? null, 'US')),
  188. 'phone' => $this->normalizePhone($user['phone'] ?? ''),
  189. 'birthdate' => $this->stringOrDefault($user['birthdate'] ?? null, '1970-01-01'),
  190. 'email' => $this->emailOrDefault($user['email'] ?? '', $userId),
  191. 'first_name' => $this->stringOrDefault($user['first_name'] ?? ($names[0] ?? null), 'unknown'),
  192. 'last_name' => $this->stringOrDefault($user['last_name'] ?? ($names[1] ?? null), 'user'),
  193. 'zip' => $this->stringOrDefault($user['zip'] ?? null, '00000'),
  194. 'city' => $this->stringOrDefault($user['city'] ?? null, 'unknown'),
  195. 'state' => $this->stringOrDefault($user['state'] ?? null, 'NA'),
  196. 'address' => $this->stringOrDefault($user['address'] ?? null, 'unknown'),
  197. 'timestamp' => $timestamp ?? $this->milliseconds(),
  198. ];
  199. }
  200. protected function rsaEncrypt($value)
  201. {
  202. $publicKey = $this->config['publicKey'] ?? '';
  203. if ($publicKey === '') {
  204. throw new Exception('PayPlus public key is empty.');
  205. }
  206. if (!class_exists('\phpseclib\Crypt\RSA')) {
  207. throw new Exception('phpseclib is required for PayPlus RSA OAEP SHA-512.');
  208. }
  209. $rsa = new \phpseclib\Crypt\RSA();
  210. $rsa->setEncryptionMode(\phpseclib\Crypt\RSA::ENCRYPTION_OAEP);
  211. $rsa->setHash('sha512');
  212. $rsa->setMGFHash('sha512');
  213. $rsa->loadKey($this->normalizePublicKey($publicKey));
  214. $encrypted = $rsa->encrypt($value);
  215. if ($encrypted === false) {
  216. throw new Exception('PayPlus RSA encrypt failed.');
  217. }
  218. return base64_encode($encrypted);
  219. }
  220. protected function buildPayinHeaders()
  221. {
  222. return [
  223. 'Content-Type: application/json',
  224. 'x-api-key: ' . ($this->config['apiKey'] ?? ''),
  225. 'x-client-id: ' . ($this->config['clientId'] ?? ''),
  226. 'x-app-id: ' . ($this->config['appId'] ?? ''),
  227. ];
  228. }
  229. protected function curlJson($url, array $payload, array $headers)
  230. {
  231. $data = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  232. $ch = curl_init();
  233. curl_setopt($ch, CURLOPT_URL, $url);
  234. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  235. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  236. curl_setopt($ch, CURLOPT_POST, true);
  237. curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
  238. curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20);
  239. curl_setopt($ch, CURLOPT_TIMEOUT, 20);
  240. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  241. curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  242. $result = curl_exec($ch);
  243. if (curl_errno($ch)) {
  244. $error = curl_error($ch);
  245. curl_close($ch);
  246. Util::WriteLog('PayPlus_error', 'CURL Error: ' . $error);
  247. throw new Exception($error);
  248. }
  249. curl_close($ch);
  250. return $result;
  251. }
  252. protected function normalizePublicKey($key)
  253. {
  254. if (strpos($key, 'BEGIN PUBLIC KEY') !== false) {
  255. return $key;
  256. }
  257. return "-----BEGIN PUBLIC KEY-----\n"
  258. . chunk_split($key, 64, "\n")
  259. . "-----END PUBLIC KEY-----";
  260. }
  261. protected function normalizePhone($phone)
  262. {
  263. $phone = trim((string) $phone);
  264. if ($phone === '') {
  265. return '+1-0000000000';
  266. }
  267. if (strpos($phone, '+') === 0) {
  268. return $phone;
  269. }
  270. return '+1-' . preg_replace('/\D+/', '', $phone);
  271. }
  272. protected function emailOrDefault($email, $userId)
  273. {
  274. $email = trim((string) $email);
  275. if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
  276. return $email;
  277. }
  278. return 'unknown' . $userId . '@example.com';
  279. }
  280. protected function stringOrDefault($value, $default)
  281. {
  282. $value = trim((string) $value);
  283. return $value === '' ? $default : $value;
  284. }
  285. protected function milliseconds()
  286. {
  287. return (int) round(microtime(true) * 1000);
  288. }
  289. private function isAssoc(array $value)
  290. {
  291. return array_keys($value) !== range(0, count($value) - 1);
  292. }
  293. }