PayPlus.php 13 KB

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