|
|
@@ -0,0 +1,424 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services;
|
|
|
+
|
|
|
+use App\Util;
|
|
|
+use Exception;
|
|
|
+
|
|
|
+class PayPlus
|
|
|
+{
|
|
|
+ const PAYIN_CONFIG = 'PayPlus';
|
|
|
+ const PAYOUT_CONFIG = 'PayPlusOut';
|
|
|
+
|
|
|
+ protected $config;
|
|
|
+
|
|
|
+ public function __construct(array $config = null)
|
|
|
+ {
|
|
|
+ if ($config === null) {
|
|
|
+ $config = (new PayConfig())->getConfig(self::PAYIN_CONFIG) ?: [];
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->config = $config;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getConfig()
|
|
|
+ {
|
|
|
+ return $this->config;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getPayoutService()
|
|
|
+ {
|
|
|
+ return new self((new PayConfig())->getConfig(self::PAYOUT_CONFIG) ?: []);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function signPayoutPayload(array $payload)
|
|
|
+ {
|
|
|
+ return hash('sha256', $this->buildPayoutSignString($payload) . ($this->config['appKey'] ?? ''));
|
|
|
+ }
|
|
|
+
|
|
|
+ public function verifyPayoutSignature(array $payload, $signature)
|
|
|
+ {
|
|
|
+ $sign = hash(
|
|
|
+ 'sha256',
|
|
|
+ $this->buildPayoutSignString($this->formatPayoutNotifyNumbers($payload)) . ($this->config['appKey'] ?? '')
|
|
|
+ );
|
|
|
+
|
|
|
+ return hash_equals($sign, strtolower((string) $signature));
|
|
|
+ }
|
|
|
+
|
|
|
+ public function buildPayoutSignString(array $payload)
|
|
|
+ {
|
|
|
+ $flat = $this->flattenPayload($payload);
|
|
|
+ ksort($flat);
|
|
|
+
|
|
|
+ $items = [];
|
|
|
+ foreach ($flat as $key => $value) {
|
|
|
+ if ($value === null || (is_string($value) && trim($value) === '')) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_bool($value)) {
|
|
|
+ $value = $value ? 'true' : 'false';
|
|
|
+ }
|
|
|
+
|
|
|
+ $items[] = $key . '=' . $value;
|
|
|
+ }
|
|
|
+
|
|
|
+ return implode('&', $items);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function flattenPayload(array $payload, $prefix = '')
|
|
|
+ {
|
|
|
+ $result = [];
|
|
|
+ foreach ($payload as $key => $value) {
|
|
|
+ if ($key === 'Authorization' || $key === 'sign') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $flatKey = $prefix === '' ? $key : $prefix . '_' . $key;
|
|
|
+ if (is_array($value) && $this->isAssoc($value)) {
|
|
|
+ $result += $this->flattenPayload($value, $flatKey);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_array($value)) {
|
|
|
+ $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
+ }
|
|
|
+
|
|
|
+ $result[$flatKey] = $value;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function encryptComponentDelta($payload, $aesKey, $iv)
|
|
|
+ {
|
|
|
+ $tag = '';
|
|
|
+ $cipherText = openssl_encrypt(
|
|
|
+ $payload,
|
|
|
+ 'aes-256-gcm',
|
|
|
+ hex2bin($aesKey),
|
|
|
+ OPENSSL_RAW_DATA,
|
|
|
+ hex2bin($iv),
|
|
|
+ $tag,
|
|
|
+ '',
|
|
|
+ 16
|
|
|
+ );
|
|
|
+
|
|
|
+ if ($cipherText === false) {
|
|
|
+ throw new Exception('PayPlus AES encrypt failed.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return base64_encode(hex2bin($iv) . $cipherText . $tag);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function decryptComponentDelta($componentDelta, $aesKey, $iv)
|
|
|
+ {
|
|
|
+ $raw = base64_decode($componentDelta, true);
|
|
|
+ if ($raw === false || strlen($raw) <= 28) {
|
|
|
+ throw new Exception('PayPlus AES response is invalid.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $cipherTextWithTag = substr($raw, 12);
|
|
|
+ $tag = substr($cipherTextWithTag, -16);
|
|
|
+ $cipherText = substr($cipherTextWithTag, 0, -16);
|
|
|
+
|
|
|
+ $plainText = openssl_decrypt(
|
|
|
+ $cipherText,
|
|
|
+ 'aes-256-gcm',
|
|
|
+ hex2bin($aesKey),
|
|
|
+ OPENSSL_RAW_DATA,
|
|
|
+ hex2bin($iv),
|
|
|
+ $tag
|
|
|
+ );
|
|
|
+
|
|
|
+ if ($plainText === false) {
|
|
|
+ throw new Exception('PayPlus AES decrypt failed.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return $plainText;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function encryptPayinPayload(array $payload)
|
|
|
+ {
|
|
|
+ $aesKey = bin2hex(random_bytes(32));
|
|
|
+ $iv = bin2hex(random_bytes(12));
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'aes_key' => $aesKey,
|
|
|
+ 'iv' => $iv,
|
|
|
+ 'body' => [
|
|
|
+ 'componentX' => $this->rsaEncrypt($aesKey),
|
|
|
+ 'componentY' => $this->rsaEncrypt($iv),
|
|
|
+ 'componentDelta' => $this->encryptComponentDelta(
|
|
|
+ json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
|
+ $aesKey,
|
|
|
+ $iv
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function decryptPayinResponse(array $response, $aesKey, $iv)
|
|
|
+ {
|
|
|
+ $componentDelta = $response['data']['componentDelta'] ?? '';
|
|
|
+ if ($componentDelta === '') {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ return json_decode($this->decryptComponentDelta($componentDelta, $aesKey, $iv), true) ?: [];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function postPayin(array $payload)
|
|
|
+ {
|
|
|
+ return $this->postPayinPath('/up-apis/merchant/payment', $payload);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function queryPayinOrder($platformOrderId, $orderId = '')
|
|
|
+ {
|
|
|
+ if ($platformOrderId) {
|
|
|
+ $payload = [
|
|
|
+ 'platform_order_id' => (string) $platformOrderId,
|
|
|
+ ];
|
|
|
+ } elseif ($orderId) {
|
|
|
+ $payload = [
|
|
|
+ 'order_id' => (string) $orderId,
|
|
|
+ ];
|
|
|
+ } else {
|
|
|
+ throw new Exception('Either platformOrderId or orderId must be provided for querying PayPlus order.');
|
|
|
+ }
|
|
|
+
|
|
|
+ Util::WriteLog('PayPlus', 'Query PayPlus order: ' . json_encode($payload));
|
|
|
+
|
|
|
+ return $this->postPayinPath(
|
|
|
+ $this->config['query_path'] ?? '/up-apis/merchant/payment/detail',
|
|
|
+ $payload
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function postPayinPath($path, array $payload)
|
|
|
+ {
|
|
|
+ $encrypted = $this->encryptPayinPayload($payload);
|
|
|
+ $response = $this->curlJson(
|
|
|
+ rtrim($this->config['apiUrl'] ?? '', '/') . $path,
|
|
|
+ $encrypted['body'],
|
|
|
+ $this->buildPayinHeaders()
|
|
|
+ );
|
|
|
+
|
|
|
+ $decoded = json_decode($response, true) ?: [];
|
|
|
+ Util::WriteLog('PayPlus', 'PayPlus raw response: ' . json_encode($decoded));
|
|
|
+ $componentDelta = $this->decryptPayinResponse($decoded, $encrypted['aes_key'], $encrypted['iv']);
|
|
|
+ if (!empty($componentDelta)) {
|
|
|
+ Util::WriteLog('PayPlus', 'PayPlus decrypted componentDelta: ' . json_encode($componentDelta));
|
|
|
+ }
|
|
|
+ $decoded['decryptedComponentDelta'] = $componentDelta;
|
|
|
+ return $decoded;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function queryPayoutOrder($transactionId = '', $referenceId = '')
|
|
|
+ {
|
|
|
+ $payload = [];
|
|
|
+ if ($transactionId !== '') {
|
|
|
+ $payload['transaction_id'] = (string) $transactionId;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($referenceId !== '') {
|
|
|
+ $payload['reference_id'] = (string) $referenceId;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (empty($payload)) {
|
|
|
+ throw new Exception('Either transactionId or referenceId must be provided for querying PayPlus payout.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->postPayout(
|
|
|
+ $this->config['payout_query_path'] ?? '/rest/v2/payouts/detail',
|
|
|
+ $payload
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public function postPayout(string $path, array $payload)
|
|
|
+ {
|
|
|
+ $payload['timestamp'] = $payload['timestamp'] ?? $this->milliseconds();
|
|
|
+ $headers = [
|
|
|
+ 'Content-Type: application/json',
|
|
|
+ 'AppId: ' . ($this->config['appId'] ?? ''),
|
|
|
+ 'Authorization: ' . $this->signPayoutPayload($payload),
|
|
|
+ ];
|
|
|
+ Util::WriteLog('PayPlus', 'PayPlus request(' . $path . '): ' . json_encode($payload) . ' | Headers: ' . json_encode($headers));
|
|
|
+ $response = $this->curlJson(rtrim($this->config['apiUrl'] ?? '', '/') . $path, $payload, $headers);
|
|
|
+ Util::WriteLog('PayPlus', 'PayPlus response(' . $path . '): ' . json_encode($response));
|
|
|
+ return json_decode($response, true) ?: [];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function buildBeneficiaryPayload(array $user, $timestamp = null)
|
|
|
+ {
|
|
|
+ $userId = (string) ($user['user_id'] ?? $user['payee_id'] ?? 0);
|
|
|
+ $name = trim((string) ($user['name'] ?? $user['user_name'] ?? ''));
|
|
|
+ $names = preg_split('/\s+/', $name, 2);
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'payee_id' => $userId,
|
|
|
+ 'language' => $this->stringOrDefault($user['language'] ?? null, 'en'),
|
|
|
+ 'country' => strtoupper($this->stringOrDefault($user['country'] ?? null, 'US')),
|
|
|
+ 'phone' => $this->normalizePhone($user['phone'] ?? ''),
|
|
|
+ 'birthdate' => $this->stringOrDefault($user['birthdate'] ?? null, '1970-01-01'),
|
|
|
+ 'email' => $this->emailOrDefault($user['email'] ?? '', $userId),
|
|
|
+ 'first_name' => $this->englishNameOrDefault($user['first_name'] ?? ($names[0] ?? null), 'unknown'),
|
|
|
+ 'last_name' => $this->englishNameOrDefault($user['last_name'] ?? ($names[1] ?? null), 'user'),
|
|
|
+ 'zip' => $this->stringOrDefault($user['zip'] ?? null, '00000'),
|
|
|
+ 'city' => $this->stringOrDefault($user['city'] ?? null, 'unknown'),
|
|
|
+ 'state' => $this->stringOrDefault($user['state'] ?? null, 'NA'),
|
|
|
+ 'address' => $this->stringOrDefault($user['address'] ?? null, 'unknown'),
|
|
|
+ 'timestamp' => $timestamp ?? $this->milliseconds(),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function rsaEncrypt($value)
|
|
|
+ {
|
|
|
+ $publicKey = $this->config['publicKey'] ?? '';
|
|
|
+ if ($publicKey === '') {
|
|
|
+ throw new Exception('PayPlus public key is empty.');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!class_exists('\phpseclib\Crypt\RSA')) {
|
|
|
+ throw new Exception('phpseclib is required for PayPlus RSA OAEP SHA-512.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $rsa = new \phpseclib\Crypt\RSA();
|
|
|
+ $rsa->setEncryptionMode(\phpseclib\Crypt\RSA::ENCRYPTION_OAEP);
|
|
|
+ $rsa->setHash('sha512');
|
|
|
+ $rsa->setMGFHash('sha512');
|
|
|
+ $rsa->loadKey($this->normalizePublicKey($publicKey));
|
|
|
+
|
|
|
+ $encrypted = $rsa->encrypt($value);
|
|
|
+ if ($encrypted === false) {
|
|
|
+ throw new Exception('PayPlus RSA encrypt failed.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return base64_encode($encrypted);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function buildPayinHeaders()
|
|
|
+ {
|
|
|
+ return [
|
|
|
+ 'Content-Type: application/json',
|
|
|
+ 'x-api-key: ' . ($this->config['apiKey'] ?? ''),
|
|
|
+ 'x-client-id: ' . ($this->config['clientId'] ?? ''),
|
|
|
+ 'x-app-id: ' . ($this->config['appId'] ?? ''),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function curlJson($url, array $payload, array $headers)
|
|
|
+ {
|
|
|
+ $data = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
+ $ch = curl_init();
|
|
|
+ curl_setopt($ch, CURLOPT_URL, $url);
|
|
|
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
|
|
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
|
+ curl_setopt($ch, CURLOPT_POST, true);
|
|
|
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
|
|
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20);
|
|
|
+ curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
|
|
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
|
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
|
+
|
|
|
+ $result = curl_exec($ch);
|
|
|
+ $curl_errno = curl_errno($ch);
|
|
|
+ if ($curl_errno) {
|
|
|
+ $error = curl_error($ch);
|
|
|
+ curl_close($ch);
|
|
|
+ Util::WriteLog('PayPlus_error', 'CURL Error: ' . "$curl_errno" . $error);
|
|
|
+ throw new Exception($error);
|
|
|
+ }
|
|
|
+
|
|
|
+ curl_close($ch);
|
|
|
+
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function normalizePublicKey($key)
|
|
|
+ {
|
|
|
+ if (strpos($key, 'BEGIN PUBLIC KEY') !== false) {
|
|
|
+ return $key;
|
|
|
+ }
|
|
|
+
|
|
|
+ return "-----BEGIN PUBLIC KEY-----\n"
|
|
|
+ . chunk_split($key, 64, "\n")
|
|
|
+ . "-----END PUBLIC KEY-----";
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function normalizePhone($phone)
|
|
|
+ {
|
|
|
+ $phone = trim((string) $phone);
|
|
|
+ if ($phone === '') {
|
|
|
+ return '+1-0000000000';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (strpos($phone, '+') === 0) {
|
|
|
+ return $phone;
|
|
|
+ }
|
|
|
+
|
|
|
+ return '+1-' . preg_replace('/\D+/', '', $phone);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function emailOrDefault($email, $userId)
|
|
|
+ {
|
|
|
+ $email = trim((string) $email);
|
|
|
+ if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
|
+ return $email;
|
|
|
+ }
|
|
|
+
|
|
|
+ return 'unknown' . $userId . '@example.com';
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function stringOrDefault($value, $default)
|
|
|
+ {
|
|
|
+ $value = trim((string) $value);
|
|
|
+
|
|
|
+ return $value === '' ? $default : $value;
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function englishNameOrDefault($value, $default)
|
|
|
+ {
|
|
|
+ $value = preg_replace('/[^A-Za-z]+/', '', (string) $value);
|
|
|
+
|
|
|
+ return $this->stringOrDefault($value, $default);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function formatPayoutNotifyNumbers(array $payload)
|
|
|
+ {
|
|
|
+ foreach ($payload as $key => $value) {
|
|
|
+ if (is_array($value)) {
|
|
|
+ $payload[$key] = $this->formatPayoutNotifyNumbers($value);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($this->isPayoutNotifyAmountKey($key) && is_numeric($value)) {
|
|
|
+ $payload[$key] = number_format((float) $value, 4, '.', '');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $payload;
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function isPayoutNotifyAmountKey($key)
|
|
|
+ {
|
|
|
+ return in_array($key, [
|
|
|
+ 'amount',
|
|
|
+ 'payout_amount',
|
|
|
+ 'source_amount',
|
|
|
+ 'payout_fee',
|
|
|
+ 'fee',
|
|
|
+ ], true);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function milliseconds()
|
|
|
+ {
|
|
|
+ return (int) round(microtime(true) * 1000);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function isAssoc(array $value)
|
|
|
+ {
|
|
|
+ return array_keys($value) !== range(0, count($value) - 1);
|
|
|
+ }
|
|
|
+}
|