PayPlus.php 10 KB

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