浏览代码

Merge branch 'main' of ssh://git.mixtech.dev.br:22622/TooEasy/backend-php

EduLascala 4 周之前
父节点
当前提交
f99fa3a650

+ 34 - 0
controllers/CompanyCheckStatus.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Psr\Http\Message\ServerRequestInterface;
+use Respect\Validation\Exceptions\ValidationException;
+use Respect\Validation\Validator as val;
+use React\Http\Message\Response;
+
+class CompanyCheckStatus
+{
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $body = json_decode((string)$request->getBody(), true) ?? [];
+
+        try {
+            val::key('numberToken', val::stringType()->notEmpty()->length(1, 255))
+                ->assert($body);
+        } catch (ValidationException $e) {
+            return ResponseLib::sendFail("Validation failed: " . $e->getFullMessage(), [], "E_VALIDATE")->withStatus(400);
+        }
+
+        $numberToken = trim($body['numberToken']);
+        $mockStatuses = [
+            'abcd-efgh-1234' => 1,
+            'ijkl-mnop-5678' => 2,
+        ];
+
+        $status = $mockStatuses[$numberToken] ?? 0;
+
+        return Response::json(['status' => $status])->withStatus(200);
+    }
+}

+ 38 - 9
controllers/CompanyWithUserController.php

@@ -6,19 +6,24 @@ use Libs\BashExecutor;
 use Libs\ResponseLib;
 use Models\CompanyModel;
 use Models\UserModel;
+use Services\TshieldService;
+
 use Psr\Http\Message\ServerRequestInterface;
 use Respect\Validation\Validator as val;
 use Respect\Validation\Exceptions\ValidationException;
+use React\Http\Message\Response;
 
 class CompanyWithUserController
 {
     private CompanyModel $companyModel;
     private UserModel $userModel;
+    private TshieldService $tshieldService;
 
     public function __construct()
     {
         $this->companyModel = new CompanyModel();
         $this->userModel = new UserModel();
+        $this->tshieldService = new TshieldService();
     }
 
     public function __invoke(ServerRequestInterface $request)
@@ -36,7 +41,7 @@ class CompanyWithUserController
                 ->key('state', val::stringType()->notEmpty()->length(1, 100))
                 ->key('zip', val::stringType()->notEmpty()->length(1, 20))
                 ->key('country', val::stringType()->notEmpty()->length(1, 100))
-                ->key('kyc', val::intType())
+                ->key('kyc', val::intType(), false)
                 ->key('birthdate', val::intType())
                 ->key('cpf', val::stringType()->notEmpty()->length(1, 11))
                 ->key('cnpj', val::stringType()->notEmpty()->length(1, 14))
@@ -68,7 +73,7 @@ class CompanyWithUserController
                 'state' => $body['state'],
                 'zip' => $body['zip'],
                 'country' => $body['country'],
-                'kyc' => (int)$body['kyc'],
+                'kyc' => isset($body['kyc']) ? (int)$body['kyc'] : 0,
                 'birthdate' => (int)$body['birthdate'],
                 'cpf' => $body['cpf'],
                 'company_id' => $companyId,
@@ -124,13 +129,37 @@ class CompanyWithUserController
 
             $pdo->commit();
 
-            return ResponseLib::sendOk([
-                'company_id' => $companyId,
-                'role_id' => $roleId,
-                'user' => $userData,
-                'wallet_id' => $walletId,
-                'wallet_address' => $parsed['address']
-            ], 'S_CREATED');
+            $analysisPayload = [
+                'corporate_name' => $body['company_name'],
+                'cnpj' => $body['cnpj'],
+                'partners' => [[
+                    'cpf' => $body['cpf'],
+                    'email' => $body['email'],
+                    'phone' => $body['phone'],
+                ]],
+            ];
+
+            try {
+                $tshield = $this->tshieldService->generateCompanyLink(
+                    (int)$userData['user_id'],
+                    $analysisPayload
+                );
+            } catch (\Throwable $e) {
+                return ResponseLib::sendFail('TShield link generation failed: ' . $e->getMessage(), [], 'E_EXTERNAL')->withStatus(502);
+            }
+
+            $payload = [
+                'status' => 'ok',
+                'link' => $tshield['link'],
+                'numberToken' => $tshield['number'],
+                'data' => [
+                    'link' => $tshield['link'],
+                    'numberToken' => $tshield['number'],
+                    'tshield' => $tshield,
+                ]
+            ];
+
+            return Response::json($payload)->withStatus(201);
 
         } catch (\Throwable $e) {
             if (isset($pdo) && $pdo->inTransaction()) { $pdo->rollBack(); }

+ 105 - 0
controllers/LoginController.php

@@ -5,6 +5,7 @@ namespace Controllers;
 use Firebase\JWT\JWT;
 use Libs\ResponseLib;
 use Models\UserModel;
+use Services\TshieldService;
 use Psr\Http\Message\ServerRequestInterface;
 use Respect\Validation\Validator as val;
 use Respect\Validation\Exceptions\ValidationException;
@@ -12,10 +13,12 @@ use Respect\Validation\Exceptions\ValidationException;
 class LoginController
 {
     private UserModel $userModel;
+    private TshieldService $tshieldService;
 
     public function __construct()
     {
         $this->userModel = new UserModel();
+        $this->tshieldService = new TshieldService();
     }
 
     public function __invoke(ServerRequestInterface $request)
@@ -39,6 +42,57 @@ class LoginController
             return ResponseLib::sendFail("Invalid credentials", [], "E_VALIDATE")->withStatus(401);
         }
 
+        $kycStatus = (int)($user['user_kyc'] ?? 0);
+        $roleId = (int)($user['role_id'] ?? 0);
+
+        if ($kycStatus === 0) {
+            if ($roleId === 1) {
+                return ResponseLib::sendFail(
+                    'Necessário finalizar análise PJ ou contatar o suporte.',
+                    ['reason' => 'KYC_PJ_PENDING'],
+                    'E_KYC'
+                )->withStatus(403);
+            }
+
+            $analysisPayload = [
+                'name' => $user['user_name'] ?? $user['user_email'],
+                'document' => $this->buildDocumentPayload($user['user_cpf'] ?? ''),
+                'email' => $user['user_email'],
+                'phone' => $user['user_phone'] ?? '',
+                'birthdate' => $this->formatBirthdate($user['user_birthdate'] ?? null),
+            ];
+
+            if (empty($analysisPayload['document']['documentNumber'])) {
+                return ResponseLib::sendFail(
+                    'CPF não cadastrado. Contate o suporte para concluir a verificação.',
+                    ['reason' => 'KYC_PF_MISSING_DOCUMENT'],
+                    'E_KYC'
+                )->withStatus(403);
+            }
+
+            try {
+                $tshield = $this->tshieldService->generateIndividualLink(
+                    (int)$user['user_id'],
+                    $analysisPayload
+                );
+            } catch (\Throwable $e) {
+                return ResponseLib::sendFail(
+                    'Não foi possível gerar o link de verificação: ' . $e->getMessage(),
+                    [],
+                    'E_EXTERNAL'
+                )->withStatus(502);
+            }
+
+            return ResponseLib::sendFail(
+                'KYC pendente. Conclua a verificação pelo link disponibilizado.',
+                [
+                    'link' => $tshield['link'],
+                    'numberToken' => $tshield['number'],
+                ],
+                'E_KYC'
+            )->withStatus(403);
+        }
+
         $payload = [
             'sub' => $user['user_id'],
             'email' => $user['user_email'],
@@ -49,4 +103,55 @@ class LoginController
 
         return ResponseLib::sendOk(['token' => $jwt, 'user_id' => $user['user_id'], 'company_id' => $user['company_id']]);
     }
+
+    private function buildDocumentPayload(?string $cpf): array
+    {
+        $number = preg_replace('/\D+/', '', (string)$cpf);
+        return [
+            'documentNumber' => $number,
+            'documentType' => 'CPF',
+        ];
+    }
+
+    private function formatBirthdate($value): ?string
+    {
+        if ($value === null || $value === '') {
+            return null;
+        }
+
+        if (is_numeric($value)) {
+            $timestamp = (int)$value;
+            if ($timestamp > 0) {
+                if (strlen((string)$timestamp) === 8) {
+                    $formatted = \DateTimeImmutable::createFromFormat('Ymd', (string)$timestamp);
+                    if ($formatted) {
+                        return $formatted->format('Y-m-d');
+                    }
+                }
+                if ($timestamp >= 1000000000) {
+                    $dt = (new \DateTimeImmutable())->setTimestamp($timestamp);
+                    return $dt->format('Y-m-d');
+                }
+            }
+        }
+
+        if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
+            return $value;
+        }
+
+        return (string)$value;
+    }
+
+    private function formatAddress(array $user): ?string
+    {
+        $parts = array_filter([
+            $user['user_address'] ?? null,
+            $user['user_city'] ?? null,
+            $user['user_state'] ?? null,
+            $user['user_zip'] ?? null,
+            $user['user_country'] ?? null,
+        ]);
+
+        return empty($parts) ? null : implode(', ', $parts);
+    }
 }

+ 0 - 0
controllers/PixQRController.php


+ 3 - 2
controllers/RegisterController.php

@@ -31,16 +31,17 @@ class RegisterController
                 ->key('state', val::stringType()->notEmpty()->length(1, 100))
                 ->key('zip', val::stringType()->notEmpty()->length(1, 20))
                 ->key('country', val::stringType()->notEmpty()->length(1, 100))
-                ->key('kyc', val::intType())
                 ->key('birthdate', val::intType())
                 ->key('cpf', val::stringType()->notEmpty()->length(1, 50))
                 ->key('company_id', val::intType()->positive())
-                ->key('role_id', val::intType()->positive())
                 ->assert($body);
         } catch (ValidationException $e) {
             return ResponseLib::sendFail("Validation failed: " . $e->getFullMessage(), [], "E_VALIDATE")->withStatus(400);
         }
 
+        $body['kyc'] = 0;
+        $body['role_id'] = 2;
+
         $userData = $this->userModel->createUser($body);
 
         if (!$userData) {

+ 34 - 0
controllers/UserCheckStatus.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Psr\Http\Message\ServerRequestInterface;
+use Respect\Validation\Exceptions\ValidationException;
+use Respect\Validation\Validator as val;
+use React\Http\Message\Response;
+
+class UserCheckStatus
+{
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $body = json_decode((string)$request->getBody(), true) ?? [];
+
+        try {
+            val::key('numberToken', val::stringType()->notEmpty()->length(1, 255))
+                ->assert($body);
+        } catch (ValidationException $e) {
+            return ResponseLib::sendFail("Validation failed: " . $e->getFullMessage(), [], "E_VALIDATE")->withStatus(400);
+        }
+
+        $numberToken = trim($body['numberToken']);
+        $mockStatuses = [
+            'abcd-efgh-1234' => 1,
+            'ijkl-mnop-5678' => 2,
+        ];
+
+        $status = $mockStatuses[$numberToken] ?? 0;
+
+        return Response::json(['status' => $status])->withStatus(200);
+    }
+}

+ 85 - 0
middlewares/CorsMiddleware.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace Middlewares;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use React\Http\Message\Response;
+use React\Promise\PromiseInterface;
+
+class CorsMiddleware
+{
+    private ?string $forcedOrigin;
+
+    public function __construct()
+    {
+        $origin = $_ENV['CORS_ALLOWED_ORIGIN'] ?? '';
+        $this->forcedOrigin = $origin !== '' ? $origin : null;
+    }
+
+    public function __invoke(ServerRequestInterface $request, callable $next)
+    {
+        $origin = $this->resolveOrigin($request);
+
+        if (strtoupper($request->getMethod()) === 'OPTIONS') {
+            return $this->preflightResponse($origin);
+        }
+
+        $response = $next($request);
+
+        return $this->decorateResponse($response, $origin);
+    }
+
+    /**
+     * @param ResponseInterface|PromiseInterface $response
+     * @return ResponseInterface|PromiseInterface
+     */
+    private function decorateResponse($response, string $origin)
+    {
+        if ($response instanceof PromiseInterface) {
+            return $response->then(function ($actual) use ($origin) {
+                return $this->decorateResponse($actual, $origin);
+            });
+        }
+
+        if ($response instanceof ResponseInterface) {
+            return $this->applyHeaders($response, $origin);
+        }
+
+        return $response;
+    }
+
+    private function preflightResponse(string $origin): ResponseInterface
+    {
+        return $this->applyHeaders(
+            Response::plaintext('')->withStatus(204),
+            $origin
+        );
+    }
+
+    private function applyHeaders(ResponseInterface $response, string $origin): ResponseInterface
+    {
+        $response = $response
+            ->withHeader('Access-Control-Allow-Origin', $origin)
+            ->withHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
+            ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
+            ->withHeader('Access-Control-Max-Age', '86400')
+            ->withHeader('Vary', 'Origin');
+
+        if ($origin !== '*') {
+            $response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
+        }
+
+        return $response;
+    }
+
+    private function resolveOrigin(ServerRequestInterface $request): string
+    {
+        if ($this->forcedOrigin !== null) {
+            return $this->forcedOrigin;
+        }
+
+        $origin = $request->getHeaderLine('Origin');
+        return $origin !== '' ? $origin : '*';
+    }
+}

+ 1 - 0
migrations/migrations_v1.sql

@@ -38,6 +38,7 @@ CREATE TABLE "user" (
     "user_zip" TEXT NOT NULL,
     "user_country" TEXT NOT NULL,
     "user_kyc" INTEGER NOT NULL,
+    "kyc_external_id" TEXT NOT NULL,
     "user_birthdate" INTEGER NOT NULL,
     "user_cpf" TEXT NOT NULL,
     "company_id" INTEGER NOT NULL,

+ 81 - 23
models/UserModel.php

@@ -16,7 +16,27 @@ class UserModel
 
     public function validateLogin(string $email, string $password): ?array
     {
-        $stmt = $this->pdo->prepare('SELECT user_id, user_email, user_password, company_id FROM "user" WHERE user_email = :email AND user_flag = \'a\'');
+        $stmt = $this->pdo->prepare(
+            'SELECT
+                user_id,
+                user_name,
+                user_email,
+                user_password,
+                user_phone,
+                user_cpf,
+                user_address,
+                user_city,
+                user_state,
+                user_zip,
+                user_country,
+                user_birthdate,
+                company_id,
+                role_id,
+                user_kyc,
+                kyc_external_id
+            FROM "user"
+            WHERE user_email = :email AND user_flag = \'a\''
+        );
         $stmt->execute(['email' => $email]);
         $user = $stmt->fetch(\PDO::FETCH_ASSOC);
 
@@ -41,30 +61,59 @@ class UserModel
 
         $stmt = $this->pdo->prepare(
             'INSERT INTO "user" (
-                user_name, user_email, user_password, user_phone, user_address, user_city, user_state, user_zip, user_country,
-                user_kyc, user_birthdate, user_cpf, company_id, role_id, user_flag
+                user_name,
+                user_email,
+                user_password,
+                user_phone,
+                user_address,
+                user_city,
+                user_state,
+                user_zip,
+                user_country,
+                user_kyc,
+                kyc_external_id,
+                user_birthdate,
+                user_cpf,
+                company_id,
+                role_id,
+                user_flag
             ) VALUES (
-                :user_name, :user_email, :hash, :user_phone, :user_address, :user_city, :user_state, :user_zip, :user_country,
-                :user_kyc, :user_birthdate, :user_cpf, :company_id, :role_id, :flag
+                :user_name,
+                :user_email,
+                :hash,
+                :user_phone,
+                :user_address,
+                :user_city,
+                :user_state,
+                :user_zip,
+                :user_country,
+                :user_kyc,
+                :kyc_external_id,
+                :user_birthdate,
+                :user_cpf,
+                :company_id,
+                :role_id,
+                :flag
             ) RETURNING user_id'
         );
 
         $ok = $stmt->execute([
-            'user_name' => $data['username'],
-            'user_email' => $data['email'],
-            'hash' => $hash,
-            'user_phone' => $data['phone'],
-            'user_address' => $data['address'],
-            'user_city' => $data['city'],
-            'user_state' => $data['state'],
-            'user_zip' => $data['zip'],
-            'user_country' => $data['country'],
-            'user_kyc' => (int)$data['kyc'],
-            'user_birthdate' => (int)$data['birthdate'],
-            'user_cpf' => $data['cpf'],
-            'company_id' => (int)$data['company_id'],
-            'role_id' => (int)$data['role_id'],
-            'flag' => $flag
+            'user_name'       => $data['username'],
+            'user_email'      => $data['email'],
+            'hash'            => $hash,
+            'user_phone'      => $data['phone'],
+            'user_address'    => $data['address'],
+            'user_city'       => $data['city'],
+            'user_state'      => $data['state'],
+            'user_zip'        => $data['zip'],
+            'user_country'    => $data['country'],
+            'user_kyc'        => (int)$data['kyc'],
+            'kyc_external_id' => $data['kyc_external_id'] ?? '',
+            'user_birthdate'  => (int)$data['birthdate'],
+            'user_cpf'        => $data['cpf'],
+            'company_id'      => (int)$data['company_id'],
+            'role_id'         => (int)$data['role_id'],
+            'flag'            => $flag
         ]);
 
         if (!$ok) {
@@ -74,11 +123,11 @@ class UserModel
         $userId = $stmt->fetchColumn();
 
         return [
-            'user_id' => (int)$userId,
-            'user_name' => $data['username'],
+            'user_id'    => (int)$userId,
+            'user_name'  => $data['username'],
             'user_email' => $data['email'],
             'company_id' => (int)$data['company_id'],
-            'role_id' => (int)$data['role_id']
+            'role_id'    => (int)$data['role_id']
         ];
     }
 
@@ -119,4 +168,13 @@ class UserModel
         $up = $this->pdo->prepare('UPDATE "user" SET user_password = :hash WHERE user_id = :uid');
         return $up->execute(['hash' => $newHash, 'uid' => $userId]);
     }
+
+    public function updateKycExternalId(int $userId, string $externalId): bool
+    {
+        $stmt = $this->pdo->prepare('UPDATE "user" SET kyc_external_id = :external_id WHERE user_id = :uid');
+        return $stmt->execute([
+            'external_id' => $externalId,
+            'uid'         => $userId,
+        ]);
+    }
 }

+ 7 - 1
public/index.php

@@ -3,6 +3,7 @@
 require __DIR__ . '/../vendor/autoload.php';
 
 use FrameworkX\App;
+use Middlewares\CorsMiddleware;
 use Middlewares\JwtAuthMiddleware;
 
 $requestUri = $_SERVER['REQUEST_URI'] ?? null;
@@ -33,7 +34,10 @@ $dbPass = $_ENV['DB_PASSWORD'] ?? '';
 $GLOBALS['pdo'] = new \PDO($dsn, $dbUser, $dbPass);
 $GLOBALS['pdo']->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
 
-$app = new App();
+$corsEnabled = filter_var($_ENV['CORS'] ?? 'false', FILTER_VALIDATE_BOOLEAN);
+$globalMiddleware = $corsEnabled ? [CorsMiddleware::class] : [];
+
+$app = new App(...$globalMiddleware);
 $authJwt = new JwtAuthMiddleware();
 
 $app->post('/verify/jwt', $authJwt,\Controllers\HelloController::class);
@@ -45,6 +49,8 @@ $app->post('/user/delete', $authJwt, \Controllers\UserDeleteController::class);
 
 // Public endpoint to create company, user, and wallet in a single transaction
 $app->post('/company/user/create', \Controllers\CompanyWithUserController::class);
+$app->post('/company/user/kyc/status', \Controllers\CompanyCheckStatus::class);
+$app->post('/user/kyc/status', \Controllers\UserCheckStatus::class);
 
 // Authenticated user profile updates
 $app->post('/user/change-email', $authJwt, \Controllers\UserChangeEmailController::class);

+ 336 - 0
services/TshieldService.php

@@ -0,0 +1,336 @@
+<?php
+
+namespace Services;
+
+use Models\UserModel;
+
+class TshieldService
+{
+    private string $baseUrl;
+    private string $login;
+    private string $password;
+    private string $clientId;
+    private string $validationIdPf;
+    private string $validationIdPj;
+    private int $timeout = 30;
+    private ?string $token = null;
+    private UserModel $userModel;
+    private array $individualFieldMap;
+
+    public function __construct()
+    {
+        $this->baseUrl = rtrim($_ENV['TSHIELD_BASE_URL'] ?? '', '/');
+        $this->login = $_ENV['TSHIELD_LOGIN'] ?? '';
+        $this->password = $_ENV['TSHIELD_PASSWORD'] ?? '';
+        $this->clientId = $_ENV['TSHIELD_CLIENT'] ?? '';
+        $this->validationIdPf = $_ENV['TSHIELD_VALIDATION_ID'] ?? '';
+        $this->validationIdPj = $_ENV['TSHIELD_VALIDATION_ID_CNPJ'] ?? '';
+
+        if ($this->baseUrl === ''
+            || $this->login === ''
+            || $this->password === ''
+            || $this->clientId === ''
+            || $this->validationIdPf === ''
+            || $this->validationIdPj === ''
+        ) {
+            throw new \RuntimeException(
+                'Missing TShield configuration. Required envs: TSHIELD_BASE_URL, TSHIELD_LOGIN, ' .
+                'TSHIELD_PASSWORD, TSHIELD_CLIENT, TSHIELD_VALIDATION_ID, TSHIELD_VALIDATION_ID_CNPJ.'
+            );
+        }
+
+        $this->userModel = new UserModel();
+        $this->individualFieldMap = [
+            'name' => (int)($_ENV['TSHIELD_FIELD_NAME_ID'] ?? 16175),
+            'document' => (int)($_ENV['TSHIELD_FIELD_DOCUMENT_ID'] ?? 16176),
+            'birthdate' => (int)($_ENV['TSHIELD_FIELD_BIRTHDATE_ID'] ?? 16177),
+            'phone' => (int)($_ENV['TSHIELD_FIELD_PHONE_ID'] ?? 16178),
+            'email' => (int)($_ENV['TSHIELD_FIELD_EMAIL_ID'] ?? 16179),
+        ];
+    }
+
+    /**
+     * Cria análise e link de KYC para pessoa física.
+     *
+     * @param int $userId           Usuário local que receberá o número externo.
+     * @param array $analysisPayload Payload aceito por POST /api/query.
+     * @param array $linkPayload     Payload adicional para POST /api/query/external/token (por exemplo, redirectUrl).
+     *
+     * @return array{
+     *     number: string,
+     *     link: ?string,
+     *     analysis: array<mixed>,
+     *     link_response: array<mixed>
+     * }
+     */
+    public function generateIndividualLink(int $userId, array $analysisPayload, array $linkPayload = []): array
+    {
+        $linkPayload = $this->buildIndividualLinkPayload($analysisPayload, $linkPayload);
+        $linkResponse = $this->createLink($this->validationIdPf, $linkPayload);
+        $analysisNumber = $linkResponse['number']
+            ?? ($linkResponse['data']['number'] ?? ($linkResponse['data']['token'] ?? null));
+
+        if (!$analysisNumber) {
+            throw new \RuntimeException(sprintf(
+                'Unable to extract analysis number from PF link response: %s',
+                json_encode($linkResponse, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+            ));
+        }
+
+        $this->persistExternalId($userId, (string)$analysisNumber);
+        $link = $linkResponse['link']
+            ?? $linkResponse['url']
+            ?? ($linkResponse['data']['link'] ?? ($linkResponse['data']['url'] ?? null));
+
+        return [
+            'number' => (string)$analysisNumber,
+            'link' => $link,
+            'analysis' => $linkPayload,
+            'link_response' => $linkResponse,
+        ];
+    }
+
+    /**
+     * Cria análise e link de KYC para pessoa jurídica.
+     *
+     * @param int   $userId
+     * @param array $analysisPayload Payload aceito por POST /api/query/company.
+     * @param array $linkPayload
+     *
+     * @return array{
+     *     number: string,
+     *     link: ?string,
+     *     analysis: array<mixed>,
+     *     link_response: array<mixed>
+     * }
+     */
+    public function generateCompanyLink(int $userId, array $analysisPayload, array $linkPayload = []): array
+    {
+        $analysisPayload = $this->injectDefaults($analysisPayload, $this->validationIdPj);
+        $analysisNumber = $this->createAnalysis('/api/query/company', $analysisPayload);
+        $this->persistExternalId($userId, $analysisNumber);
+
+        return [
+            'number' => $analysisNumber,
+            'link' => null,
+            'analysis' => $analysisPayload,
+            'link_response' => [
+                'data' => [
+                    'client_validation_id' => $this->validationIdPj,
+                    'number' => $analysisNumber,
+                ],
+            ],
+        ];
+    }
+
+    private function createAnalysis(string $path, array $payload): string
+    {
+        $response = $this->request('POST', $path, $payload);
+
+        $number = $response['data']['number']
+            ?? $response['number']
+            ?? $response['data']['client_validation_id']
+            ?? $response['client_validation_id']
+            ?? null;
+
+        if (!$number) {
+            throw new \RuntimeException(sprintf(
+                'Unable to extract analysis number from %s response: %s',
+                $path,
+                json_encode($response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+            ));
+        }
+
+        return (string)$number;
+    }
+
+    private function createLink(string $validationId, array $payload): array
+    {
+        $body = array_merge(['client_validation_id' => $validationId], $payload);
+        $response = $this->request('POST', '/api/query/external/token', $body);
+
+        if (
+            !isset($response['link']) &&
+            !isset($response['url']) &&
+            !isset($response['data']['link']) &&
+            !isset($response['data']['url'])
+        ) {
+            throw new \RuntimeException(sprintf(
+                'Invalid link response from TShield: %s',
+                json_encode($response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+            ));
+        }
+
+        return $response;
+    }
+
+    private function persistExternalId(int $userId, string $externalId): void
+    {
+        if (!$this->userModel->updateKycExternalId($userId, $externalId)) {
+            throw new \RuntimeException('Unable to persist KYC external id for user ' . $userId);
+        }
+    }
+
+    private function ensureToken(): void
+    {
+        if ($this->token !== null) {
+            return;
+        }
+
+        $loginPayload = [
+            'login' => $this->login,
+            'password' => $this->password,
+            'client' => $this->clientId,
+        ];
+
+        $loginResponse = $this->request('POST', '/api/login', $loginPayload, false);
+
+        $token = $loginResponse['token']
+            ?? ($loginResponse['data']['token'] ?? ($loginResponse['access_token'] ?? null))
+            ?? ($loginResponse['user']['token'] ?? null);
+        if ($token === null) {
+            throw new \RuntimeException('TShield login did not return a token.');
+        }
+
+        $this->token = (string) $token;
+    }
+
+    private function request(string $method, string $path, array $payload = [], bool $requiresAuth = true): array
+    {
+        if ($requiresAuth) {
+            $this->ensureToken();
+        }
+
+        $url = $this->baseUrl . $path;
+        $ch = curl_init($url);
+
+        $headers = ['Content-Type: application/json'];
+        if ($requiresAuth && $this->token !== null) {
+            $headers[] = 'Authorization: Bearer ' . $this->token;
+        }
+
+        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
+        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+
+        if (!empty($payload)) {
+            $encoded = json_encode($payload);
+            if ($encoded === false) {
+                throw new \RuntimeException('Unable to encode payload to JSON.');
+            }
+            curl_setopt($ch, CURLOPT_POSTFIELDS, $encoded);
+        }
+
+        $this->logRequest($method, $url, $payload);
+
+        $responseBody = curl_exec($ch);
+        $curlErrNo = curl_errno($ch);
+        $curlErr = curl_error($ch);
+        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        curl_close($ch);
+
+        if ($curlErrNo !== 0) {
+            throw new \RuntimeException(sprintf('cURL error while calling %s: %s (%d)', $url, $curlErr, $curlErrNo));
+        }
+
+        if ($responseBody === false || $responseBody === '') {
+            throw new \RuntimeException(sprintf('Empty response from TShield endpoint %s', $url));
+        }
+
+        $decoded = json_decode($responseBody, true);
+        if ($decoded === null) {
+            throw new \RuntimeException(sprintf('Invalid JSON response from %s: %s', $url, $responseBody));
+        }
+
+        if ($httpCode >= 400) {
+            $message = $decoded['message'] ?? $decoded['error'] ?? 'TShield request failed';
+            throw new \RuntimeException(sprintf(
+                '%s (HTTP %d) payload=%s',
+                $message,
+                $httpCode,
+                json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+            ));
+        }
+
+        return $decoded;
+    }
+
+    private function logRequest(string $method, string $url, array $payload): void
+    {
+        $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+        error_log(sprintf('[TShield] %s %s payload=%s', $method, $url, $encoded));
+    }
+
+    private function buildIndividualLinkPayload(array $analysisPayload, array $linkPayload): array
+    {
+        $payload = $linkPayload;
+        if (!isset($payload['token_send'])) {
+            $payload['token_send'] = [];
+        }
+
+        $defaultAdditional = [];
+        $fieldValues = [
+            'name' => $analysisPayload['name'] ?? null,
+            'document' => $analysisPayload['document'] ?? null,
+            'birthdate' => $analysisPayload['birthdate'] ?? null,
+            'phone' => $analysisPayload['phone'] ?? null,
+            'email' => $analysisPayload['email'] ?? null,
+        ];
+
+        foreach ($fieldValues as $key => $value) {
+            if (is_array($value)) {
+                $value = $value['number'] ?? reset($value) ?? null;
+            }
+
+            if (!$value) {
+                continue;
+            }
+            $fieldId = $this->individualFieldMap[$key] ?? null;
+            if (!$fieldId) {
+                continue;
+            }
+            $defaultAdditional[] = [
+                'client_validation_additional_field_id' => $fieldId,
+                'value' => $value,
+                'file_field_type' => null,
+                'values' => [$value],
+            ];
+        }
+
+        if (!isset($payload['additional_fields']) || !is_array($payload['additional_fields'])) {
+            $payload['additional_fields'] = $defaultAdditional;
+        } elseif (!empty($defaultAdditional)) {
+            $payload['additional_fields'] = array_merge($defaultAdditional, $payload['additional_fields']);
+        }
+
+        if (empty($payload['company_id']) && isset($_ENV['TSHIELD_COMPANY_ID'])) {
+            $payload['company_id'] = $_ENV['TSHIELD_COMPANY_ID'];
+        }
+
+        return $payload;
+    }
+
+    private function injectDefaults(array $payload, string $validationId): array
+    {
+        if (empty($payload['client_validation_id'])) {
+            $payload['client_validation_id'] = $validationId;
+        }
+        if (empty($payload['clientValidationId'])) {
+            $payload['clientValidationId'] = $validationId;
+        }
+        if (empty($payload['validationId'])) {
+            $payload['validationId'] = $validationId;
+        }
+
+        if (empty($payload['client'])) {
+            $payload['client'] = $this->clientId;
+        }
+
+        if (empty($payload['cliente'])) {
+            $payload['cliente'] = $this->clientId;
+        }
+
+        return $payload;
+    }
+}