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.'); } $details = $this->getPublicKeyDetails($publicKey); $encoded = $this->oaepSha512Encode((string) $value, strlen($details['n'])); $encrypted = ''; $success = openssl_public_encrypt( $encoded, $encrypted, $this->normalizePublicKey($publicKey), OPENSSL_NO_PADDING ); if (!$success) { throw new Exception('PayPlus RSA encrypt failed.'); } return base64_encode($encrypted); } protected function getPublicKeyDetails($publicKey) { $resource = openssl_pkey_get_public($this->normalizePublicKey($publicKey)); if ($resource === false) { throw new Exception('PayPlus public key is invalid.'); } $details = openssl_pkey_get_details($resource); if (!isset($details['rsa']['n'], $details['rsa']['e'])) { throw new Exception('PayPlus RSA public key details are invalid.'); } return $details['rsa']; } protected function oaepSha512Encode($message, $keyLength) { $hashLength = 64; $messageLength = strlen($message); if ($messageLength > $keyLength - 2 * $hashLength - 2) { throw new Exception('PayPlus RSA message is too long.'); } $labelHash = hash('sha512', '', true); $padding = str_repeat("\x00", $keyLength - $messageLength - 2 * $hashLength - 2); $dataBlock = $labelHash . $padding . "\x01" . $message; $seed = random_bytes($hashLength); $maskedDataBlock = $dataBlock ^ $this->mgf1Sha512($seed, $keyLength - $hashLength - 1); $maskedSeed = $seed ^ $this->mgf1Sha512($maskedDataBlock, $hashLength); return "\x00" . $maskedSeed . $maskedDataBlock; } protected function mgf1Sha512($seed, $length) { $mask = ''; $counter = 0; while (strlen($mask) < $length) { $mask .= hash('sha512', $seed . pack('N', $counter), true); $counter++; } return substr($mask, 0, $length); } 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); } }