TshieldService.php 15 KB

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