TshieldService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. <?php
  2. namespace Services;
  3. use Models\UserModel;
  4. class TshieldService
  5. {
  6. private string $baseUrl;
  7. private string $login;
  8. private string $password;
  9. private string $clientId;
  10. private string $validationIdPf;
  11. private string $validationIdPj;
  12. private int $timeout = 30;
  13. private ?string $token = null;
  14. private UserModel $userModel;
  15. private array $individualFieldMap;
  16. private string $logFilePath;
  17. public function __construct()
  18. {
  19. $this->baseUrl = rtrim($_ENV['TSHIELD_BASE_URL'] ?? '', '/');
  20. $this->login = $_ENV['TSHIELD_LOGIN'] ?? '';
  21. $this->password = $_ENV['TSHIELD_PASSWORD'] ?? '';
  22. $this->clientId = $_ENV['TSHIELD_CLIENT'] ?? '';
  23. $this->validationIdPf = $_ENV['TSHIELD_VALIDATION_ID'] ?? '';
  24. $this->validationIdPj = $_ENV['TSHIELD_VALIDATION_ID_CNPJ'] ?? '';
  25. if ($this->baseUrl === ''
  26. || $this->login === ''
  27. || $this->password === ''
  28. || $this->clientId === ''
  29. || $this->validationIdPf === ''
  30. || $this->validationIdPj === ''
  31. ) {
  32. throw new \RuntimeException(
  33. 'Missing TShield configuration. Required envs: TSHIELD_BASE_URL, TSHIELD_LOGIN, ' .
  34. 'TSHIELD_PASSWORD, TSHIELD_CLIENT, TSHIELD_VALIDATION_ID, TSHIELD_VALIDATION_ID_CNPJ.'
  35. );
  36. }
  37. $this->userModel = new UserModel();
  38. $this->logFilePath = $this->resolveLogFilePath($_ENV['TSHIELD_LOG_FILE'] ?? null);
  39. $this->individualFieldMap = [
  40. 'name' => (int)($_ENV['TSHIELD_FIELD_NAME_ID'] ?? 16175),
  41. 'document' => (int)($_ENV['TSHIELD_FIELD_DOCUMENT_ID'] ?? 16176),
  42. 'birthdate' => (int)($_ENV['TSHIELD_FIELD_BIRTHDATE_ID'] ?? 16177),
  43. 'phone' => (int)($_ENV['TSHIELD_FIELD_PHONE_ID'] ?? 16178),
  44. 'email' => (int)($_ENV['TSHIELD_FIELD_EMAIL_ID'] ?? 16179),
  45. ];
  46. }
  47. /**
  48. * Cria análise e link de KYC para pessoa física.
  49. *
  50. * @param int $userId Usuário local que receberá o número externo.
  51. * @param array $analysisPayload Payload aceito por POST /api/query.
  52. * @param array $linkPayload Payload adicional para POST /api/query/external/token (por exemplo, redirectUrl).
  53. *
  54. * @return array{
  55. * number: string,
  56. * link: ?string,
  57. * analysis: array<mixed>,
  58. * link_response: array<mixed>
  59. * }
  60. */
  61. public function generateIndividualLink(int $userId, array $analysisPayload, array $linkPayload = []): array
  62. {
  63. $linkPayload = $this->buildIndividualLinkPayload($analysisPayload, $linkPayload);
  64. $linkResponse = $this->createLink($this->validationIdPf, $linkPayload);
  65. $analysisNumber = $linkResponse['number']
  66. ?? ($linkResponse['data']['number'] ?? null);
  67. if (!$analysisNumber) {
  68. throw new \RuntimeException(sprintf(
  69. 'Unable to extract analysis number from PF link response: %s',
  70. json_encode($linkResponse, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
  71. ));
  72. }
  73. $this->persistExternalId($userId, (string)$analysisNumber);
  74. $link = $linkResponse['link']
  75. ?? $linkResponse['url']
  76. ?? ($linkResponse['data']['link'] ?? ($linkResponse['data']['url'] ?? null));
  77. return [
  78. 'number' => (string)$analysisNumber,
  79. 'link' => $link,
  80. 'analysis' => $linkPayload,
  81. 'link_response' => $linkResponse,
  82. ];
  83. }
  84. /**
  85. * Cria análise e link de KYC para pessoa jurídica.
  86. *
  87. * @param int $userId
  88. * @param array $analysisPayload Payload aceito por POST /api/query/company.
  89. * @param array $linkPayload
  90. *
  91. * @return array{
  92. * number: string,
  93. * link: ?string,
  94. * analysis: array<mixed>,
  95. * link_response: array<mixed>
  96. * }
  97. */
  98. public function generateCompanyLink(int $userId, array $analysisPayload, array $linkPayload = []): array
  99. {
  100. $analysisPayload = $this->injectDefaults($analysisPayload, $this->validationIdPj);
  101. $analysisNumber = $this->createAnalysis('/api/query/company', $analysisPayload);
  102. $this->persistExternalId($userId, $analysisNumber);
  103. return [
  104. 'number' => $analysisNumber,
  105. 'link' => null,
  106. 'analysis' => $analysisPayload,
  107. 'link_response' => [
  108. 'data' => [
  109. 'client_validation_id' => $this->validationIdPj,
  110. 'number' => $analysisNumber,
  111. ],
  112. ],
  113. ];
  114. }
  115. private function createAnalysis(string $path, array $payload): string
  116. {
  117. $response = $this->request('POST', $path, $payload);
  118. $number = $response['data']['number']
  119. ?? $response['number']
  120. ?? $response['data']['client_validation_id']
  121. ?? $response['client_validation_id']
  122. ?? null;
  123. if (!$number) {
  124. throw new \RuntimeException(sprintf(
  125. 'Unable to extract analysis number from %s response: %s',
  126. $path,
  127. json_encode($response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
  128. ));
  129. }
  130. return (string)$number;
  131. }
  132. private function createLink(string $validationId, array $payload): array
  133. {
  134. $body = array_merge(['client_validation_id' => $validationId], $payload);
  135. $response = $this->request('POST', '/api/query/external/token', $body);
  136. if (
  137. !isset($response['link']) &&
  138. !isset($response['url']) &&
  139. !isset($response['data']['link']) &&
  140. !isset($response['data']['url'])
  141. ) {
  142. throw new \RuntimeException(sprintf(
  143. 'Invalid link response from TShield: %s',
  144. json_encode($response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
  145. ));
  146. }
  147. return $response;
  148. }
  149. private function persistExternalId(int $userId, string $externalId): void
  150. {
  151. if (!$this->userModel->updateKycExternalId($userId, $externalId)) {
  152. throw new \RuntimeException('Unable to persist KYC external id for user ' . $userId);
  153. }
  154. }
  155. private function ensureToken(): void
  156. {
  157. if ($this->token !== null) {
  158. return;
  159. }
  160. $loginPayload = [
  161. 'login' => $this->login,
  162. 'password' => $this->password,
  163. 'client' => $this->clientId,
  164. ];
  165. $loginResponse = $this->request('POST', '/api/login', $loginPayload, false);
  166. $token = $loginResponse['token']
  167. ?? ($loginResponse['data']['token'] ?? ($loginResponse['access_token'] ?? null))
  168. ?? ($loginResponse['user']['token'] ?? null);
  169. if ($token === null) {
  170. throw new \RuntimeException('TShield login did not return a token.');
  171. }
  172. $this->token = (string) $token;
  173. }
  174. private function request(string $method, string $path, array $payload = [], bool $requiresAuth = true): array
  175. {
  176. if ($requiresAuth) {
  177. $this->ensureToken();
  178. }
  179. $url = $this->baseUrl . $path;
  180. $ch = curl_init($url);
  181. $headers = ['Content-Type: application/json'];
  182. if ($requiresAuth && $this->token !== null) {
  183. $headers[] = 'Authorization: Bearer ' . $this->token;
  184. }
  185. curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
  186. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  187. curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
  188. curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  189. if (!empty($payload)) {
  190. $encoded = json_encode($payload);
  191. if ($encoded === false) {
  192. throw new \RuntimeException('Unable to encode payload to JSON.');
  193. }
  194. curl_setopt($ch, CURLOPT_POSTFIELDS, $encoded);
  195. }
  196. $this->logRequest($method, $url, $payload);
  197. $responseBody = curl_exec($ch);
  198. $curlErrNo = curl_errno($ch);
  199. $curlErr = curl_error($ch);
  200. $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
  201. curl_close($ch);
  202. $this->logToFile($method, $url, $httpCode, $payload, $responseBody);
  203. if ($curlErrNo !== 0) {
  204. throw new \RuntimeException(sprintf('cURL error while calling %s: %s (%d)', $url, $curlErr, $curlErrNo));
  205. }
  206. if ($responseBody === false || $responseBody === '') {
  207. throw new \RuntimeException(sprintf('Empty response from TShield endpoint %s', $url));
  208. }
  209. $decoded = json_decode($responseBody, true);
  210. if ($decoded === null) {
  211. throw new \RuntimeException(sprintf('Invalid JSON response from %s: %s', $url, $responseBody));
  212. }
  213. if ($httpCode >= 400) {
  214. $message = $decoded['message'] ?? $decoded['error'] ?? 'TShield request failed';
  215. throw new \RuntimeException(sprintf(
  216. '%s (HTTP %d) payload=%s',
  217. $message,
  218. $httpCode,
  219. json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
  220. ));
  221. }
  222. return $decoded;
  223. }
  224. private function resolveLogFilePath(?string $path): string
  225. {
  226. $path = trim((string)$path);
  227. if ($path === '') {
  228. $path = 'storage/logs/tshield_response.txt';
  229. }
  230. if (!preg_match('/^(?:[A-Za-z]:\\\\|\\/)/', $path)) {
  231. $path = rtrim(dirname(__DIR__), '/\\') . DIRECTORY_SEPARATOR . $path;
  232. }
  233. $dir = dirname($path);
  234. if (!is_dir($dir)) {
  235. @mkdir($dir, 0775, true);
  236. }
  237. return $path;
  238. }
  239. private function logToFile(string $method, string $url, int $httpCode, array $payload, $responseBody): void
  240. {
  241. $ts = (new \DateTimeImmutable())->format('c');
  242. $payloadEncoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
  243. $responseText = is_string($responseBody) ? $responseBody : '';
  244. $entry = "[$ts] $method $url HTTP=$httpCode\n" .
  245. "payload=$payloadEncoded\n" .
  246. "response=$responseText\n" .
  247. "----\n";
  248. try {
  249. @file_put_contents($this->logFilePath, $entry, FILE_APPEND | LOCK_EX);
  250. } catch (\Throwable $e) {
  251. }
  252. }
  253. private function logRequest(string $method, string $url, array $payload): void
  254. {
  255. $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
  256. error_log(sprintf('[TShield] %s %s payload=%s', $method, $url, $encoded));
  257. }
  258. private function buildIndividualLinkPayload(array $analysisPayload, array $linkPayload): array
  259. {
  260. $payload = $linkPayload;
  261. if (!isset($payload['token_send'])) {
  262. $payload['token_send'] = [];
  263. }
  264. $defaultAdditional = [];
  265. if (isset($analysisPayload['birthdate'])) {
  266. $analysisPayload['birthdate'] = $this->normalizeBirthdate($analysisPayload['birthdate']);
  267. }
  268. $fieldValues = [
  269. 'name' => $analysisPayload['name'] ?? null,
  270. 'document' => $analysisPayload['document'] ?? null,
  271. 'birthdate' => $analysisPayload['birthdate'] ?? null,
  272. 'phone' => $analysisPayload['phone'] ?? null,
  273. 'email' => $analysisPayload['email'] ?? null,
  274. ];
  275. foreach ($fieldValues as $key => $value) {
  276. if (is_array($value)) {
  277. $value = $value['number'] ?? reset($value) ?? null;
  278. }
  279. if (!$value) {
  280. continue;
  281. }
  282. $fieldId = $this->individualFieldMap[$key] ?? null;
  283. if (!$fieldId) {
  284. continue;
  285. }
  286. $defaultAdditional[] = [
  287. 'client_validation_additional_field_id' => $fieldId,
  288. 'value' => $value,
  289. 'file_field_type' => null,
  290. 'values' => [$value],
  291. ];
  292. }
  293. if (!isset($payload['additional_fields']) || !is_array($payload['additional_fields'])) {
  294. $payload['additional_fields'] = $defaultAdditional;
  295. } elseif (!empty($defaultAdditional)) {
  296. $payload['additional_fields'] = array_merge($defaultAdditional, $payload['additional_fields']);
  297. }
  298. if (empty($payload['company_id']) && isset($_ENV['TSHIELD_COMPANY_ID'])) {
  299. $payload['company_id'] = $_ENV['TSHIELD_COMPANY_ID'];
  300. }
  301. return $payload;
  302. }
  303. private function normalizeBirthdate($value): ?string
  304. {
  305. if ($value === null || $value === '') {
  306. return null;
  307. }
  308. if (is_numeric($value)) {
  309. $numeric = (string)$value;
  310. $timestamp = (int)$value;
  311. if ($timestamp > 0) {
  312. if (strlen($numeric) === 8) {
  313. $formatted = \DateTimeImmutable::createFromFormat('Ymd', $numeric);
  314. if ($formatted) {
  315. return $formatted->format('Y-m-d');
  316. }
  317. }
  318. if (strlen($numeric) >= 13) {
  319. $timestamp = (int) floor($timestamp / 1000);
  320. }
  321. if ($timestamp >= 1000000000) {
  322. $dt = (new \DateTimeImmutable())->setTimestamp($timestamp);
  323. return $dt->format('Y-m-d');
  324. }
  325. }
  326. }
  327. if (is_string($value)) {
  328. $value = trim($value);
  329. if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
  330. return $value;
  331. }
  332. if (preg_match('/^\d{8}$/', $value)) {
  333. $formatted = \DateTimeImmutable::createFromFormat('Ymd', $value);
  334. if ($formatted) {
  335. return $formatted->format('Y-m-d');
  336. }
  337. }
  338. foreach (['d/m/Y', 'd-m-Y', 'Y/m/d', 'Y.m.d'] as $fmt) {
  339. $formatted = \DateTimeImmutable::createFromFormat($fmt, $value);
  340. if ($formatted) {
  341. return $formatted->format('Y-m-d');
  342. }
  343. }
  344. }
  345. return null;
  346. }
  347. private function injectDefaults(array $payload, string $validationId): array
  348. {
  349. if (empty($payload['client_validation_id'])) {
  350. $payload['client_validation_id'] = $validationId;
  351. }
  352. return $payload;
  353. }
  354. }