PayPlus.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  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. } else if ($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 postPayout(string $path, array $payload)
  175. {
  176. $payload['timestamp'] = $payload['timestamp'] ?? $this->milliseconds();
  177. $headers = [
  178. 'Content-Type: application/json',
  179. 'AppId: ' . ($this->config['appId'] ?? ''),
  180. 'Authorization: ' . $this->signPayoutPayload($payload),
  181. ];
  182. Util::WriteLog('PayPlus', 'PayPlus request(' . $path . '): ' . json_encode($payload) . ' | Headers: ' . json_encode($headers));
  183. $response = $this->curlJson(rtrim($this->config['apiUrl'] ?? '', '/') . $path, $payload, $headers);
  184. Util::WriteLog('PayPlus', 'PayPlus response(' . $path . '): ' . json_encode($response));
  185. return json_decode($response, true) ?: [];
  186. }
  187. public function buildBeneficiaryPayload(array $user, $timestamp = null)
  188. {
  189. $userId = (string) ($user['user_id'] ?? $user['payee_id'] ?? 0);
  190. $name = trim((string) ($user['name'] ?? $user['user_name'] ?? ''));
  191. $names = preg_split('/\s+/', $name, 2);
  192. return [
  193. 'payee_id' => $userId,
  194. 'language' => $this->stringOrDefault($user['language'] ?? null, 'en'),
  195. 'country' => strtoupper($this->stringOrDefault($user['country'] ?? null, 'US')),
  196. 'phone' => $this->normalizePhone($user['phone'] ?? ''),
  197. 'birthdate' => $this->stringOrDefault($user['birthdate'] ?? null, '1970-01-01'),
  198. 'email' => $this->emailOrDefault($user['email'] ?? '', $userId),
  199. 'first_name' => $this->englishNameOrDefault($user['first_name'] ?? ($names[0] ?? null), 'unknown'),
  200. 'last_name' => $this->englishNameOrDefault($user['last_name'] ?? ($names[1] ?? null), 'user'),
  201. 'zip' => $this->stringOrDefault($user['zip'] ?? null, '00000'),
  202. 'city' => $this->stringOrDefault($user['city'] ?? null, 'unknown'),
  203. 'state' => $this->stringOrDefault($user['state'] ?? null, 'NA'),
  204. 'address' => $this->stringOrDefault($user['address'] ?? null, 'unknown'),
  205. 'timestamp' => $timestamp ?? $this->milliseconds(),
  206. ];
  207. }
  208. protected function rsaEncrypt($value)
  209. {
  210. $publicKey = $this->config['publicKey'] ?? '';
  211. if ($publicKey === '') {
  212. throw new Exception('PayPlus public key is empty.');
  213. }
  214. if (!class_exists('\phpseclib\Crypt\RSA')) {
  215. throw new Exception('phpseclib is required for PayPlus RSA OAEP SHA-512.');
  216. }
  217. $rsa = new \phpseclib\Crypt\RSA();
  218. $rsa->setEncryptionMode(\phpseclib\Crypt\RSA::ENCRYPTION_OAEP);
  219. $rsa->setHash('sha512');
  220. $rsa->setMGFHash('sha512');
  221. $rsa->loadKey($this->normalizePublicKey($publicKey));
  222. $encrypted = $rsa->encrypt($value);
  223. if ($encrypted === false) {
  224. throw new Exception('PayPlus RSA encrypt failed.');
  225. }
  226. return base64_encode($encrypted);
  227. }
  228. protected function buildPayinHeaders()
  229. {
  230. return [
  231. 'Content-Type: application/json',
  232. 'x-api-key: ' . ($this->config['apiKey'] ?? ''),
  233. 'x-client-id: ' . ($this->config['clientId'] ?? ''),
  234. 'x-app-id: ' . ($this->config['appId'] ?? ''),
  235. ];
  236. }
  237. protected function curlJson($url, array $payload, array $headers)
  238. {
  239. $data = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  240. $ch = curl_init();
  241. curl_setopt($ch, CURLOPT_URL, $url);
  242. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  243. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  244. curl_setopt($ch, CURLOPT_POST, true);
  245. curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
  246. curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20);
  247. curl_setopt($ch, CURLOPT_TIMEOUT, 20);
  248. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  249. curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  250. $result = curl_exec($ch);
  251. $curl_errno = curl_errno($ch);
  252. if ($curl_errno) {
  253. $error = curl_error($ch);
  254. curl_close($ch);
  255. Util::WriteLog('PayPlus_error', 'CURL Error: ' . "$curl_errno" . $error);
  256. throw new Exception($error);
  257. }
  258. curl_close($ch);
  259. return $result;
  260. }
  261. protected function normalizePublicKey($key)
  262. {
  263. if (strpos($key, 'BEGIN PUBLIC KEY') !== false) {
  264. return $key;
  265. }
  266. return "-----BEGIN PUBLIC KEY-----\n"
  267. . chunk_split($key, 64, "\n")
  268. . "-----END PUBLIC KEY-----";
  269. }
  270. protected function normalizePhone($phone)
  271. {
  272. $phone = trim((string) $phone);
  273. if ($phone === '') {
  274. return '+1-0000000000';
  275. }
  276. if (strpos($phone, '+') === 0) {
  277. return $phone;
  278. }
  279. return '+1-' . preg_replace('/\D+/', '', $phone);
  280. }
  281. protected function emailOrDefault($email, $userId)
  282. {
  283. $email = trim((string) $email);
  284. if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
  285. return $email;
  286. }
  287. return 'unknown' . $userId . '@example.com';
  288. }
  289. protected function stringOrDefault($value, $default)
  290. {
  291. $value = trim((string) $value);
  292. return $value === '' ? $default : $value;
  293. }
  294. protected function englishNameOrDefault($value, $default)
  295. {
  296. $value = preg_replace('/[^A-Za-z]+/', '', (string) $value);
  297. return $this->stringOrDefault($value, $default);
  298. }
  299. protected function milliseconds()
  300. {
  301. return (int) round(microtime(true) * 1000);
  302. }
  303. private function isAssoc(array $value)
  304. {
  305. return array_keys($value) !== range(0, count($value) - 1);
  306. }
  307. }