소스 검색

implementacao didit - validacao de documentos

Gustavo Zanatta 3 일 전
부모
커밋
9db741f703
53개의 변경된 파일3799개의 추가작업 그리고 29개의 파일을 삭제
  1. 12 0
      .env.example
  2. 125 0
      app/Commands/SyncDiditSessions.php
  3. 41 0
      app/Data/Didit/DiditData.php
  4. 21 0
      app/Data/Didit/Session/Parts/Request/ContactDetailsData.php
  5. 42 0
      app/Data/Didit/Session/Parts/Request/ExpectedDetailsData.php
  6. 34 0
      app/Data/Didit/Session/SessionRequestData.php
  7. 41 0
      app/Data/Didit/Session/SessionResponseData.php
  8. 58 0
      app/Enums/IdentityVerificationStatusEnum.php
  9. 101 0
      app/Http/Controllers/VerificationController.php
  10. 150 0
      app/Http/Controllers/WebhookController.php
  11. 44 0
      app/Http/Middleware/EnsureClientIsVerified.php
  12. 8 1
      app/Http/Middleware/EnsureProviderIsAccepted.php
  13. 1 0
      app/Http/Requests/RegisterClientRequest.php
  14. 0 2
      app/Http/Requests/RegisterProviderRequest.php
  15. 4 0
      app/Http/Resources/ClientResource.php
  16. 32 0
      app/Http/Resources/IdentityVerificationResource.php
  17. 6 0
      app/Http/Resources/ProviderResource.php
  18. 12 0
      app/Models/Client.php
  19. 68 0
      app/Models/IdentityVerification.php
  20. 5 0
      app/Models/Provider.php
  21. 10 0
      app/Models/User.php
  22. 6 1
      app/Services/ClientService.php
  23. 6 0
      app/Services/CustomScheduleService.php
  24. 106 0
      app/Services/Didit/Concerns/SendsDiditRequests.php
  25. 106 0
      app/Services/Didit/DiditDecisionExtractor.php
  26. 259 0
      app/Services/Didit/DiditDecisionService.php
  27. 156 0
      app/Services/Didit/DiditSessionService.php
  28. 171 0
      app/Services/IdentityVerificationService.php
  29. 8 24
      app/Services/ProviderService.php
  30. 54 0
      app/Services/WebhookService.php
  31. 25 0
      app/Tasks/SyncStaleDiditVerifications.php
  32. 15 0
      bootstrap/app.php
  33. 8 0
      config/logging.php
  34. 16 0
      config/services.php
  35. 54 0
      database/migrations/2026_09_08_100001_create_identity_verifications_table.php
  36. 46 0
      database/migrations/2026_09_08_100002_add_identity_verification_to_providers_and_clients.php
  37. 6 0
      database/seeders/PermissionSeeder.php
  38. 1 0
      database/seeders/UserTypePermissionSeeder.php
  39. 1 0
      lang/en/messages.php
  40. 1 0
      lang/es/messages.php
  41. 24 0
      lang/pt/identity_verification.php
  42. 1 0
      lang/pt/messages.php
  43. 54 0
      resources/views/verification/callback.blade.php
  44. 1 1
      routes/authRoutes/schedule.php
  45. 17 0
      routes/authRoutes/verification.php
  46. 10 0
      routes/console.php
  47. 7 0
      routes/noAuthRoutes/didit_callback.php
  48. 6 0
      routes/noAuthRoutes/didit_webhook.php
  49. 124 0
      tests/Feature/DiditMaintenanceTest.php
  50. 313 0
      tests/Feature/DiditWebhookTest.php
  51. 338 0
      tests/Fixtures/didit/decision_approved_cin.json
  52. 471 0
      tests/Fixtures/didit/decision_approved_cnh.json
  53. 573 0
      tests/Fixtures/didit/decision_in_review_cpf_mismatch.json

+ 12 - 0
.env.example

@@ -60,6 +60,18 @@ PAGARME_PLATFORM_SERVICE_PACKAGE_MIN_3_SCHEDULES_PIX_FEE_RATE=0.08
 PAGARME_TRANSFER_FEE_AMOUNT=3.67
 PAGARME_WITHDRAWAL_RELEASE_DAYS=2
 
+DIDIT_BASE_URL=https://verification.didit.me
+DIDIT_API_KEY=
+DIDIT_WEBHOOK_SECRET=
+DIDIT_WORKFLOW_PROVIDER=
+DIDIT_WORKFLOW_CLIENT=
+DIDIT_CALLBACK_URL=
+DIDIT_CONNECT_TIMEOUT=5
+DIDIT_TIMEOUT=15
+DIDIT_MAX_ATTEMPTS=3
+DIDIT_LIVENESS_MIN_SCORE=80
+DIDIT_FACE_MATCH_MIN_SCORE=85
+
 GEMINI_API_KEY=
 GEMINI_MODEL=
 

+ 125 - 0
app/Commands/SyncDiditSessions.php

@@ -0,0 +1,125 @@
+<?php
+
+namespace App\Commands;
+
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Models\IdentityVerification;
+use App\Services\Didit\DiditDecisionService;
+use App\Services\Didit\DiditSessionService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+/**
+ * Reconcilia verificacoes cujo webhook nao chegou.
+ *
+ * O Didit tenta entregar 3 vezes (imediata, ~1min, ~4min) e desiste. Se a API
+ * estiver fora do ar nessa janela, a verificacao fica presa em Not Started ou
+ * In Progress para sempre. Este comando busca a decisao pela API e aplica o
+ * mesmo caminho do webhook.
+ */
+class SyncDiditSessions extends Command
+{
+    protected $signature = 'didit:sync
+        {session_id? : Sessao especifica a reconciliar}
+        {--stale-hours=6 : Idade minima, em horas, para considerar uma verificacao travada}
+        {--limit=50 : Maximo de verificacoes por execucao}
+        {--dry-run : Apenas lista o que seria reconciliado}';
+
+    protected $description = 'Reconcilia verificacoes de identidade cujo webhook do Didit nao chegou';
+
+    public function __construct(
+        private readonly DiditSessionService $sessionService,
+        private readonly DiditDecisionService $decisionService,
+    ) {
+        parent::__construct();
+    }
+
+    public function handle(): int
+    {
+        $verifications = $this->targets();
+
+        if ($verifications->isEmpty()) {
+            $this->info('Nenhuma verificacao para reconciliar.');
+
+            return self::SUCCESS;
+        }
+
+        $this->info("Verificacoes a reconciliar: {$verifications->count()}");
+
+        if ($this->option('dry-run')) {
+            $this->table(
+                ['id', 'session_id', 'status', 'usuario', 'iniciada em'],
+                $verifications->map(fn (IdentityVerification $v) => [
+                    $v->id, $v->session_id, $v->didit_status, $v->user_id, (string) $v->started_at,
+                ])->all(),
+            );
+
+            return self::SUCCESS;
+        }
+
+        $reconciled = 0;
+        $unchanged  = 0;
+        $failed     = 0;
+
+        foreach ($verifications as $verification) {
+            try {
+                $decision = $this->sessionService->getDecision($verification->session_id);
+                $status   = (string) data_get($decision, 'status');
+
+                if ($status === '' || $status === $verification->didit_status) {
+                    $unchanged++;
+
+                    continue;
+                }
+
+                // Mesmo caminho do webhook: o payload real tambem traz o decision inteiro.
+                $this->decisionService->handleWebhook([
+                    'session_id'   => $verification->session_id,
+                    'webhook_type' => 'status.updated',
+                    'status'       => $status,
+                    'vendor_data'  => (string) $verification->user_id,
+                    'workflow_id'  => $verification->workflow_id,
+                    'decision'     => $decision,
+                ]);
+
+                $this->line("  #{$verification->id} {$verification->didit_status} -> {$status}");
+
+                $reconciled++;
+            } catch (Throwable $e) {
+                $failed++;
+
+                $this->error("  #{$verification->id}: {$e->getMessage()}");
+
+                Log::channel('didit')->error('Falha ao reconciliar sessao', [
+                    'session_id' => $verification->session_id,
+                    'exception'  => $e->getMessage(),
+                ]);
+            }
+        }
+
+        $this->newLine();
+        $this->info("Reconciliadas: {$reconciled} | sem mudanca: {$unchanged} | falhas: {$failed}");
+
+        return $failed > 0 ? self::FAILURE : self::SUCCESS;
+    }
+
+    //
+
+    /** @return \Illuminate\Support\Collection<int, IdentityVerification> */
+    private function targets()
+    {
+        $query = IdentityVerification::query();
+
+        if ($sessionId = $this->argument('session_id')) {
+            return $query->where('session_id', $sessionId)->get();
+        }
+
+        return $query
+            ->whereIn('didit_status', ['Not Started', 'In Progress'])
+            ->where('started_at', '<=', now()->subHours((int) $this->option('stale-hours')))
+            ->orderBy('started_at')
+            ->limit((int) $this->option('limit'))
+            ->get();
+    }
+}

+ 41 - 0
app/Data/Didit/DiditData.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Data\Didit;
+
+abstract readonly class DiditData
+{
+    abstract public function toArray(): array;
+
+    protected static function digits(?string $value): string
+    {
+        return preg_replace('/\D+/', '', (string) $value) ?? '';
+    }
+
+    protected function filterFilledRecursive(array $data): array
+    {
+        $filtered = [];
+
+        foreach ($data as $key => $value) {
+            if ($value instanceof self) {
+                $value = $value->toArray();
+            }
+
+            if (is_array($value)) {
+                $value = $this->filterFilledRecursive($value);
+            }
+
+            if ($value !== null && $value !== '' && $value !== []) {
+                $filtered[$key] = $value;
+            }
+        }
+
+        return $filtered;
+    }
+
+    protected static function requireFilled(mixed $value, string $field): void
+    {
+        if ($value === null || $value === '' || $value === []) {
+            throw new \InvalidArgumentException("{$field} e obrigatorio.");
+        }
+    }
+}

+ 21 - 0
app/Data/Didit/Session/Parts/Request/ContactDetailsData.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Data\Didit\Session\Parts\Request;
+
+use App\Data\Didit\DiditData;
+
+final readonly class ContactDetailsData extends DiditData
+{
+    public function __construct(
+        public ?string $email = null,
+        public ?string $phone = null,
+    ) {}
+
+    public function toArray(): array
+    {
+        return $this->filterFilledRecursive([
+            'email' => $this->email,
+            'phone' => $this->phone,
+        ]);
+    }
+}

+ 42 - 0
app/Data/Didit/Session/Parts/Request/ExpectedDetailsData.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Data\Didit\Session\Parts\Request;
+
+use App\Data\Didit\DiditData;
+
+final readonly class ExpectedDetailsData extends DiditData
+{
+    public function __construct(
+        public ?string $firstName            = null,
+        public ?string $lastName             = null,
+        public ?string $dateOfBirth          = null,
+        public ?string $identificationNumber = null,
+        public string  $idCountry            = 'BRA',
+    ) {}
+
+    public static function fromName(
+        ?string $fullName,
+        ?string $dateOfBirth = null,
+        ?string $document = null,
+    ): self {
+        $parts = preg_split('/\s+/', trim((string) $fullName), 2) ?: [];
+
+        return new self(
+            firstName:            $parts[0] ?? null,
+            lastName:             $parts[1] ?? null,
+            dateOfBirth:          $dateOfBirth,
+            identificationNumber: $document ? self::digits($document) : null,
+        );
+    }
+
+    public function toArray(): array
+    {
+        return $this->filterFilledRecursive([
+            'first_name'            => $this->firstName,
+            'last_name'             => $this->lastName,
+            'date_of_birth'         => $this->dateOfBirth,
+            'identification_number' => $this->identificationNumber,
+            'id_country'            => $this->idCountry,
+        ]);
+    }
+}

+ 34 - 0
app/Data/Didit/Session/SessionRequestData.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Data\Didit\Session;
+
+use App\Data\Didit\DiditData;
+use App\Data\Didit\Session\Parts\Request\ContactDetailsData;
+use App\Data\Didit\Session\Parts\Request\ExpectedDetailsData;
+
+final readonly class SessionRequestData extends DiditData
+{
+    public function __construct(
+        public string               $workflowId,
+        public string               $vendorData,
+        public ?string              $callback        = null,
+        public string               $language        = 'pt',
+        public ?ExpectedDetailsData $expectedDetails = null,
+        public ?ContactDetailsData  $contactDetails  = null,
+    ) {
+        self::requireFilled($this->workflowId, 'workflow_id');
+        self::requireFilled($this->vendorData, 'vendor_data');
+    }
+
+    public function toArray(): array
+    {
+        return $this->filterFilledRecursive([
+            'workflow_id'      => $this->workflowId,
+            'vendor_data'      => $this->vendorData,
+            'callback'         => $this->callback,
+            'language'         => $this->language,
+            'expected_details' => $this->expectedDetails,
+            'contact_details'  => $this->contactDetails,
+        ]);
+    }
+}

+ 41 - 0
app/Data/Didit/Session/SessionResponseData.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Data\Didit\Session;
+
+use App\Data\Didit\DiditData;
+
+final readonly class SessionResponseData extends DiditData
+{
+    public function __construct(
+        public string  $sessionId,
+        public string  $url,
+        public ?string $sessionToken = null,
+        public ?string $workflowId   = null,
+        public ?string $status       = null,
+        public ?string $vendorData   = null,
+    ) {}
+
+    public static function fromResponse(array $response): self
+    {
+        return new self(
+            sessionId:    (string) data_get($response, 'session_id'),
+            url:          (string) data_get($response, 'url'),
+            sessionToken: data_get($response, 'session_token'),
+            workflowId:   data_get($response, 'workflow_id'),
+            status:       data_get($response, 'status'),
+            vendorData:   data_get($response, 'vendor_data'),
+        );
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'session_id'    => $this->sessionId,
+            'url'           => $this->url,
+            'session_token' => $this->sessionToken,
+            'workflow_id'   => $this->workflowId,
+            'status'        => $this->status,
+            'vendor_data'   => $this->vendorData,
+        ];
+    }
+}

+ 58 - 0
app/Enums/IdentityVerificationStatusEnum.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Enums;
+
+use App\Traits\EnumHelper;
+
+enum IdentityVerificationStatusEnum: string
+{
+    use EnumHelper;
+
+    case NOT_STARTED = 'not_started';
+    case PENDING     = 'pending';
+    case APPROVED    = 'approved';
+    case IN_REVIEW   = 'in_review';
+    case DECLINED    = 'declined';
+    case EXPIRED     = 'expired';
+
+    public static function fromDidit(string $diditStatus): self
+    {
+        return match ($diditStatus) {
+            'Approved'                     => self::APPROVED,
+            'Declined'                     => self::DECLINED,
+            'In Review', 'Resubmitted'     => self::IN_REVIEW,
+            'Not Started', 'In Progress',
+            'Awaiting User'                => self::PENDING,
+            'Abandoned', 'Expired'         => self::NOT_STARTED,
+            'Kyc Expired'                  => self::EXPIRED,
+            default                        => self::PENDING,
+        };
+    }
+
+    public static function diditTerminalStatuses(): array
+    {
+        return ['Approved', 'Declined', 'In Review', 'Abandoned'];
+    }
+
+    public function isFinished(): bool
+    {
+        return in_array($this, [self::APPROVED, self::DECLINED, self::IN_REVIEW], true);
+    }
+
+    public function allowsNewAttempt(): bool
+    {
+        return in_array($this, [self::NOT_STARTED, self::DECLINED, self::EXPIRED], true);
+    }
+
+    public function label(): string
+    {
+        return match ($this) {
+            self::NOT_STARTED => __('identity_verification.status.not_started'),
+            self::PENDING     => __('identity_verification.status.pending'),
+            self::APPROVED    => __('identity_verification.status.approved'),
+            self::IN_REVIEW   => __('identity_verification.status.in_review'),
+            self::DECLINED    => __('identity_verification.status.declined'),
+            self::EXPIRED     => __('identity_verification.status.expired'),
+        };
+    }
+}

+ 101 - 0
app/Http/Controllers/VerificationController.php

@@ -0,0 +1,101 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Resources\IdentityVerificationResource;
+use App\Services\IdentityVerificationService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Throwable;
+
+class VerificationController extends Controller
+{
+    public function __construct(
+        private readonly IdentityVerificationService $service,
+    ) {}
+
+    public function createSession(Request $request): JsonResponse
+    {
+        try {
+            return $this->successResponse(
+                payload: $this->service->startFor($request->user(), $request->boolean('native')),
+                message: __('identity_verification.session_created'),
+                code:    201,
+            );
+        } catch (Throwable $e) {
+            return $this->errorResponse(message: $e->getMessage(), code: 422);
+        }
+    }
+
+    public function callback(Request $request)
+    {
+        $schemes = [
+            'client'   => 'br.com.diarista.client',
+            'provider' => 'br.com.diarista.provider',
+        ];
+
+        return response()
+            ->view('verification.callback', [
+                'status' => $request->query('status'),
+                'scheme' => $schemes[$request->query('app')] ?? null,
+            ])
+            ->header('Cache-Control', 'no-store');
+    }
+
+    public function me(Request $request): JsonResponse
+    {
+        return $this->successResponse(payload: $this->service->statusFor($request->user()));
+    }
+
+    public function pending(Request $request): JsonResponse
+    {
+        return $this->successResponse(
+            payload: $this->service->pending(
+                perPage: (int) $request->integer('per_page', 10),
+                page:    (int) $request->integer('page', 1),
+            ),
+        );
+    }
+
+    public function show(int $id): JsonResponse
+    {
+        $verification = $this->service->findWithFreshDecision($id);
+
+        if (! $verification) {
+            return $this->errorResponse(message: __('identity_verification.not_found'), code: 404);
+        }
+
+        return $this->successResponse(payload: $verification);
+    }
+
+    public function approve(Request $request, int $id): JsonResponse
+    {
+        return $this->review($request, $id, approved: true);
+    }
+
+    public function reject(Request $request, int $id): JsonResponse
+    {
+        return $this->review($request, $id, approved: false);
+    }
+
+    //
+
+    private function review(Request $request, int $id, bool $approved): JsonResponse
+    {
+        try {
+            $verification = $this->service->review(
+                id:       $id,
+                approved: $approved,
+                reviewer: $request->user(),
+                comment:  $request->string('comment')->toString() ?: null,
+            );
+
+            return $this->successResponse(
+                payload: new IdentityVerificationResource($verification),
+                message: __('identity_verification.reviewed'),
+            );
+        } catch (Throwable $e) {
+            return $this->errorResponse(message: $e->getMessage(), code: 422);
+        }
+    }
+}

+ 150 - 0
app/Http/Controllers/WebhookController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Services\WebhookService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
 
 class WebhookController extends Controller
 {
@@ -23,6 +24,29 @@ class WebhookController extends Controller
         return $this->successResponse(message: __('http.webhook_received'));
     }
 
+    public function didit(Request $request): JsonResponse
+    {
+        $payload = $request->all();
+
+        Log::channel('didit')->info('Webhook recebido do Didit', [
+            'event_id'     => data_get($payload, 'event_id'),
+            'webhook_type' => data_get($payload, 'webhook_type'),
+            'session_id'   => data_get($payload, 'session_id'),
+            'status'       => data_get($payload, 'status'),
+            'vendor_data'  => data_get($payload, 'vendor_data'),
+            'environment'  => data_get($payload, 'environment'),
+            'is_test'      => $request->hasHeader('X-Didit-Test-Webhook'),
+        ]);
+
+        if (! $this->validDiditSignature($request)) {
+            return $this->errorResponse(message: __('http.unauthorized_token'), code: 401);
+        }
+
+        $this->webhookService->handleDidit($payload);
+
+        return $this->successResponse(message: __('http.webhook_received'));
+    }
+
     //
 
     private function validPagarmeCredentials(Request $request): bool
@@ -49,4 +73,130 @@ class WebhookController extends Controller
             && hash_equals($configuredUser, $receivedUser)
             && hash_equals($configuredPassword, $receivedPassword);
     }
+
+    private function validDiditSignature(Request $request): bool
+    {
+        $secrets = $this->diditWebhookSecrets();
+
+        if ($secrets === []) {
+            Log::channel('didit')->error('DIDIT_WEBHOOK_SECRET nao configurado; webhook rejeitado');
+
+            return false;
+        }
+
+        if (! $this->validDiditTimestamp($request)) {
+            return false;
+        }
+
+        $rawBody = $request->getContent();
+
+        $candidates = array_filter([
+            'X-Signature-V2' => $this->diditCanonicalBody($rawBody),
+            'X-Signature'    => $rawBody,
+        ], static fn ($body) => is_string($body));
+
+        foreach ($candidates as $header => $body) {
+            $received = (string) $request->header($header, '');
+
+            if ($received === '') {
+                continue;
+            }
+
+            foreach ($secrets as $secret) {
+                if (hash_equals(hash_hmac('sha256', $body, $secret), $received)) {
+                    return true;
+                }
+            }
+        }
+
+        Log::channel('didit')->warning('Assinatura do webhook Didit invalida', [
+            'secrets_testados' => count($secrets),
+            'received_v2'      => substr((string) $request->header('X-Signature-V2', ''), 0, 12),
+            'body_length'      => strlen($rawBody),
+        ]);
+
+        return false;
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function diditWebhookSecrets(): array
+    {
+        $configured = config('services.didit.webhook_secret');
+
+        if (! is_string($configured)) {
+            return [];
+        }
+
+        return array_values(array_filter(
+            array_map('trim', explode(',', $configured)),
+            static fn (string $secret) => $secret !== '',
+        ));
+    }
+
+    private function validDiditTimestamp(Request $request): bool
+    {
+        $timestamp = $request->header('X-Timestamp');
+
+        if (! is_numeric($timestamp)) {
+            Log::channel('didit')->warning('Webhook Didit sem X-Timestamp valido');
+
+            return false;
+        }
+
+        $tolerance = (int) config('services.didit.webhook_tolerance', 300);
+
+        if (abs(time() - (int) $timestamp) > $tolerance) {
+            Log::channel('didit')->warning('Webhook Didit fora da janela de tolerancia', [
+                'timestamp' => (int) $timestamp,
+                'now'       => time(),
+            ]);
+
+            return false;
+        }
+
+        return true;
+    }
+
+    private function diditCanonicalBody(string $rawBody): ?string
+    {
+        try {
+            $decoded = json_decode($rawBody, false, 512, JSON_THROW_ON_ERROR);
+        } catch (\JsonException) {
+            return null;
+        }
+
+        $canonical = json_encode(
+            $this->canonicalizeDiditValue($decoded),
+            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+        );
+
+        return $canonical === false ? null : $canonical;
+    }
+
+    private function canonicalizeDiditValue(mixed $value): mixed
+    {
+        if (is_array($value)) {
+            return array_map(fn ($item) => $this->canonicalizeDiditValue($item), $value);
+        }
+
+        if ($value instanceof \stdClass) {
+            $data = get_object_vars($value);
+
+            ksort($data, SORT_STRING);
+
+            foreach ($data as $key => $item) {
+                $data[$key] = $this->canonicalizeDiditValue($item);
+            }
+
+            return (object) $data;
+        }
+
+        if (is_float($value) && is_finite($value) && floor($value) === $value && abs($value) < PHP_INT_MAX) {
+            return (int) $value;
+        }
+
+        return $value;
+    }
 }

+ 44 - 0
app/Http/Middleware/EnsureClientIsVerified.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class EnsureClientIsVerified
+{
+    public function handle(Request $request, Closure $next): Response
+    {
+        $user = $request->user();
+
+        if (! $user || $user->type !== UserTypeEnum::CLIENT) {
+            return $next($request);
+        }
+
+        $client = $user->client;
+
+        if (! $client || ! $client->identity_verification_required) {
+            return $next($request);
+        }
+
+        if ($client->identity_verification_status === IdentityVerificationStatusEnum::APPROVED) {
+            return $next($request);
+        }
+
+        return response()->json([
+            'payload' => [
+                'identity_verification_status' => $client->identity_verification_status?->value,
+                'attempts_left'                => $this->attemptsLeft($client->identity_verification_attempts),
+            ],
+            'message' => __('identity_verification.client_not_verified'),
+        ], 403);
+    }
+
+    private function attemptsLeft(?int $attempts): int
+    {
+        return max(0, (int) config('services.didit.max_attempts', 3) - (int) $attempts);
+    }
+}

+ 8 - 1
app/Http/Middleware/EnsureProviderIsAccepted.php

@@ -27,7 +27,14 @@ class EnsureProviderIsAccepted
 
         if ($provider->approval_status === ApprovalStatusEnum::PENDING) {
             return response()->json([
-                'payload' => ['approval_status' => ApprovalStatusEnum::PENDING->value],
+                'payload' => [
+                    'approval_status'              => ApprovalStatusEnum::PENDING->value,
+                    'identity_verification_status' => $provider->identity_verification_status?->value,
+                    'attempts_left'                => max(
+                        0,
+                        (int) config('services.didit.max_attempts', 3) - (int) $provider->identity_verification_attempts,
+                    ),
+                ],
                 'message' => __('auth.provider_pending'),
             ], 202);
         }

+ 1 - 0
app/Http/Requests/RegisterClientRequest.php

@@ -20,6 +20,7 @@ class RegisterClientRequest extends FormRequest
             'name'           => 'required|string|max:255',
             'phone'          => 'required|string|max:20',
             'document'       => 'required|string|max:20',
+            'birth_date'     => 'nullable|date|before:today',
             'zip_code'       => 'required|string|max:20',
             'number'         => 'required|string|max:20',
             'address'        => 'required|string|max:255',

+ 0 - 2
app/Http/Requests/RegisterProviderRequest.php

@@ -40,8 +40,6 @@ class RegisterProviderRequest extends FormRequest
       'daily_price_4h'  => 'required|numeric|min:0',
       'daily_price_2h'  => 'required|numeric|min:0',
       'selfie'          => 'required|file|image|mimes:jpg,jpeg,png,webp|max:5120',
-      'document_front'  => 'required|file|image|mimes:jpg,jpeg,png,webp|max:10240',
-      'document_back'   => 'required|file|image|mimes:jpg,jpeg,png,webp|max:10240',
 
       'working_days'          => 'required|array|min:1',
       'working_days.*.day'    => 'required|integer|min:0|max:6',

+ 4 - 0
app/Http/Resources/ClientResource.php

@@ -19,6 +19,10 @@ class ClientResource extends JsonResource
             'document'         => $this->document,
             'user_id'          => $this->user_id,
             'selfie_verified'  => $this->selfie_verified,
+            'birth_date'       => $this->birth_date?->format('Y-m-d'),
+            'identity_verification_status'   => $this->identity_verification_status?->value,
+            'identity_verified_at'           => $this->identity_verified_at,
+            'identity_verification_attempts' => $this->identity_verification_attempts,
             'first_access'     => $this->first_access,
             'profile_media_id' => $this->profileMedia?->id,
             'profile_media'    => $this->profileMedia ? new MediaResource($this->profileMedia) : null,

+ 32 - 0
app/Http/Resources/IdentityVerificationResource.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class IdentityVerificationResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'                         => $this->id,
+            'session_id'                 => $this->session_id,
+            'didit_status'               => $this->didit_status,
+            'attempt'                    => $this->attempt,
+            'id_verification_status'     => $this->id_verification_status,
+            'liveness_status'            => $this->liveness_status,
+            'face_match_status'          => $this->face_match_status,
+            'liveness_score'             => $this->liveness_score,
+            'face_match_score'           => $this->face_match_score,
+            'warnings'                   => $this->warnings,
+            'actionable_warnings'        => $this->actionableWarnings(),
+            'reviewed_at'                => $this->reviewed_at,
+            'review_comment'             => $this->review_comment,
+            'started_at'                 => $this->started_at,
+            'completed_at'               => $this->completed_at,
+            'user'                       => new UserResource($this->whenLoaded('user')),
+            'created_at'                 => $this->created_at,
+        ];
+    }
+}

+ 6 - 0
app/Http/Resources/ProviderResource.php

@@ -27,6 +27,12 @@ class ProviderResource extends JsonResource
             'selfie_verified'                => $this->selfie_verified,
             'document_verified'              => $this->document_verified,
             'approval_status'                => $this->approval_status?->value ?? $this->approval_status,
+            'identity_verification_status'   => $this->identity_verification_status?->value,
+            'identity_verified_at'           => $this->identity_verified_at,
+            'identity_verification_attempts' => $this->identity_verification_attempts,
+            'identity_verification'          => $this->user?->latestIdentityVerification
+                ? new IdentityVerificationResource($this->user->latestIdentityVerification)
+                : null,
             'daily_price_8h'                 => $this->daily_price_8h,
             'daily_price_6h'                 => $this->daily_price_6h,
             'daily_price_4h'                 => $this->daily_price_4h,

+ 12 - 0
app/Models/Client.php

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\Enums\IdentityVerificationStatusEnum;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -59,6 +60,7 @@ class Client extends Model
 
     protected $fillable = [
         'document',
+        'birth_date',
         'gateway_customer_id',
         'gateway_customer_code',
         'idempotency_key',
@@ -66,6 +68,10 @@ class Client extends Model
         'profile_media_id',
         'selfie_verified',
         'first_access',
+        'identity_verification_status',
+        'identity_verified_at',
+        'identity_verification_attempts',
+        'identity_verification_required',
     ];
 
     protected $casts = [
@@ -74,6 +80,12 @@ class Client extends Model
         'deleted_at'      => 'datetime',
         'selfie_verified' => 'boolean',
         'first_access'    => 'boolean',
+        'birth_date'      => 'date',
+
+        'identity_verification_status'   => IdentityVerificationStatusEnum::class,
+        'identity_verified_at'           => 'datetime',
+        'identity_verification_attempts' => 'integer',
+        'identity_verification_required' => 'boolean',
     ];
 
     public function user(): BelongsTo

+ 68 - 0
app/Models/IdentityVerification.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class IdentityVerification extends Model
+{
+    use HasFactory;
+
+    protected $table = 'identity_verifications';
+
+    protected $fillable = [
+        'user_id',
+        'session_id',
+        'session_token',
+        'workflow_id',
+        'didit_status',
+        'attempt',
+        'id_verification_status',
+        'liveness_status',
+        'face_match_status',
+        'liveness_score',
+        'face_match_score',
+        'warnings',
+        'decision',
+        'reviewed_by',
+        'reviewed_at',
+        'review_comment',
+        'started_at',
+        'completed_at',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'attempt'          => 'integer',
+            'liveness_score'   => 'float',
+            'face_match_score' => 'float',
+            'warnings'         => 'array',
+            'decision'         => 'array',
+            'reviewed_at'      => 'datetime',
+            'started_at'       => 'datetime',
+            'completed_at'     => 'datetime',
+        ];
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function reviewer(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'reviewed_by');
+    }
+
+    /** Warnings acionaveis: o Didit marca os informativos com log_type = information. */
+    public function actionableWarnings(): array
+    {
+        return array_values(array_filter(
+            $this->warnings ?? [],
+            static fn ($warning) => data_get($warning, 'log_type') === 'warning',
+        ));
+    }
+}

+ 5 - 0
app/Models/Provider.php

@@ -3,6 +3,7 @@
 namespace App\Models;
 
 use App\Enums\ApprovalStatusEnum;
+use App\Enums\IdentityVerificationStatusEnum;
 use App\Enums\GenderEnum;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
@@ -122,6 +123,10 @@ class Provider extends Model
     {
         return [
             'birth_date'                                => 'date',
+            'identity_verification_status'              => IdentityVerificationStatusEnum::class,
+            'identity_verified_at'                      => 'datetime',
+            'identity_verification_attempts'            => 'integer',
+            'identity_verification_required'            => 'boolean',
             'gender'                                    => GenderEnum::class,
             'selfie_verified'                           => 'boolean',
             'document_verified'                         => 'boolean',

+ 10 - 0
app/Models/User.php

@@ -116,6 +116,16 @@ class User extends Authenticatable
         return $this->hasOne(Provider::class, 'user_id');
     }
 
+    public function identityVerifications()
+    {
+        return $this->hasMany(IdentityVerification::class, 'user_id');
+    }
+
+    public function latestIdentityVerification()
+    {
+        return $this->hasOne(IdentityVerification::class, 'user_id')->latestOfMany();
+    }
+
     //
 
     public function deviceTokens()

+ 6 - 1
app/Services/ClientService.php

@@ -101,10 +101,15 @@ class ClientService
 
             if (! empty(data_get($data, 'document'))) {
                 $client->document = data_get($data, 'document');
+            }
 
-                $client->save();
+
+            if (! empty(data_get($data, 'birth_date'))) {
+                $client->birth_date = data_get($data, 'birth_date');
             }
 
+            $client->save();
+
             $client->refresh();
 
             if (data_get($data, 'avatar') !== null && data_get($data, 'avatar') instanceof UploadedFile) {

+ 6 - 0
app/Services/CustomScheduleService.php

@@ -68,6 +68,12 @@ class CustomScheduleService
 
     public function create(array $data)
     {
+        $address = Address::find(data_get($data, 'address_id'));
+
+        if (! $address || $address->latitude === null || $address->longitude === null) {
+            throw new \Exception(__('messages.client_address_location_required'));
+        }
+
         DB::beginTransaction();
 
         try {

+ 106 - 0
app/Services/Didit/Concerns/SendsDiditRequests.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Services\Didit\Concerns;
+
+use App\Data\Didit\DiditData;
+use Illuminate\Http\Client\PendingRequest;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+trait SendsDiditRequests
+{
+    protected function diditRequest(
+        string $method,
+        string $path,
+        string $errorMessage,
+        array|DiditData|null $payload = null,
+    ): array {
+        $payload  = $payload instanceof DiditData ? $payload->toArray() : $payload;
+        $endpoint = $this->diditUrl($path);
+
+        try {
+            $options = $payload === null ? [] : ['json' => $payload];
+
+            $response = $this->diditHttp()
+                ->send($method, $endpoint, $options)
+                ->throw();
+
+            $result = $response->json() ?? [];
+
+            if (app()->environment('local', 'development')) {
+                Log::channel('didit')->info('Requisicao ao Didit concluida com sucesso', [
+                    'method'   => strtoupper($method),
+                    'endpoint' => $endpoint,
+                    'payload'  => $this->maskDiditPayload($payload ?? []),
+                    'result'   => $this->maskDiditPayload($result),
+                ]);
+            }
+
+            return $result;
+        } catch (Throwable $e) {
+            $responseBody = null;
+
+            if (method_exists($e, 'getResponse')) {
+                $responseBody = $e->getResponse()?->json();
+            } elseif (isset($e->response)) {
+                $responseBody = $e->response->json();
+            }
+
+            Log::channel('didit')->error('Falha na requisicao ao Didit', [
+                'method'    => strtoupper($method),
+                'endpoint'  => $endpoint,
+                'payload'   => $this->maskDiditPayload($payload ?? []),
+                'exception' => $e->getMessage(),
+                'result'    => $responseBody,
+            ]);
+
+            $message = $errorMessage;
+            $detail  = data_get($responseBody, 'detail') ?? data_get($responseBody, 'message');
+
+            if (is_string($detail) && $detail !== '') {
+                $message .= ': '.$detail;
+            }
+
+            throw new \RuntimeException($message, previous: $e);
+        }
+    }
+
+    protected function diditHttp(): PendingRequest
+    {
+        return Http::withHeaders([
+            'x-api-key'    => (string) config('services.didit.api_key'),
+            'Content-Type' => 'application/json',
+        ])
+            ->connectTimeout((int) config('services.didit.connect_timeout', 5))
+            ->timeout((int) config('services.didit.timeout', 15))
+            ->acceptJson();
+    }
+
+    protected function diditUrl(string $path): string
+    {
+        return rtrim((string) config('services.didit.base_url'), '/').'/'.ltrim($path, '/');
+    }
+
+    protected function maskDiditPayload(array $data): array
+    {
+        $sensitive = [
+            'identification_number', 'personal_number', 'document_number', 'tax_number',
+            'date_of_birth', 'first_name', 'last_name', 'full_name', 'email', 'phone',
+        ];
+
+        array_walk_recursive($data, function (&$value, $key) use ($sensitive) {
+            if (in_array($key, $sensitive, true) && is_scalar($value)) {
+                $value = '***';
+
+                return;
+            }
+
+            if (is_string($value) && str_starts_with($value, 'http')) {
+                $value = explode('?', $value)[0];
+            }
+        });
+
+        return $data;
+    }
+}

+ 106 - 0
app/Services/Didit/DiditDecisionExtractor.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Services\Didit;
+
+class DiditDecisionExtractor
+{
+    public function extract(array $decision): array
+    {
+        $id       = $this->first($decision, 'id_verifications');
+        $liveness = $this->first($decision, 'liveness_checks');
+        $face     = $this->first($decision, 'face_matches');
+
+        return [
+            'id_verification_status'     => data_get($id, 'status'),
+            'liveness_status'            => data_get($liveness, 'status'),
+            'face_match_status'          => data_get($face, 'status'),
+
+            'liveness_score'   => $this->score($liveness, 'liveness_score'),
+            'face_match_score' => $this->score($face, 'face_match_score'),
+
+            'warnings' => $this->warnings($decision),
+        ];
+    }
+
+    public function extractDocument(array $decision): ?string
+    {
+        $id = $this->first($decision, 'id_verifications');
+
+        $taxNumber = data_get($id, 'extra_fields.tax_number');
+
+        return is_string($taxNumber) && $taxNumber !== ''
+            ? preg_replace('/\D+/', '', $taxNumber)
+            : null;
+    }
+
+    /** @return list<array<string, mixed>> */
+    public function warnings(array $decision): array
+    {
+        $warnings = [];
+
+        foreach ($this->featureKeys() as $key) {
+            foreach ($this->items($decision, $key) as $item) {
+                foreach ((array) data_get($item, 'warnings', []) as $warning) {
+                    $warnings[] = $warning;
+                }
+            }
+        }
+
+        return $warnings;
+    }
+
+    public function actionableWarnings(array $decision): array
+    {
+        return array_values(array_filter(
+            $this->warnings($decision),
+            static fn ($warning) => data_get($warning, 'log_type') === 'warning',
+        ));
+    }
+
+    /** @return list<string> */
+    public function featureKeys(): array
+    {
+        return [
+            'id_verifications',
+            'liveness_checks',
+            'face_matches',
+            'ip_analyses',
+            'nfc_verifications',
+            'aml_screenings',
+            'poa_verifications',
+        ];
+    }
+
+    public function items(array $decision, string $key): array
+    {
+        $value = data_get($decision, $key);
+
+        return is_array($value) ? $value : [];
+    }
+
+    public function first(array $decision, string $key): ?array
+    {
+        $items = $this->items($decision, $key);
+
+        return $items === [] ? null : (array) reset($items);
+    }
+
+    private function score(?array $item, string $fallbackKey): ?float
+    {
+        if ($item === null) {
+            return null;
+        }
+
+        $value = data_get($item, 'score')
+            ?? data_get($item, $fallbackKey)
+            ?? data_get($item, 'similarity_percentage');
+
+        if (! is_numeric($value)) {
+            return null;
+        }
+
+        $value = (float) $value;
+
+        return $value <= 1 ? round($value * 100, 2) : round($value, 2);
+    }
+}

+ 259 - 0
app/Services/Didit/DiditDecisionService.php

@@ -0,0 +1,259 @@
+<?php
+
+namespace App\Services\Didit;
+
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\Client;
+use App\Models\IdentityVerification;
+use App\Models\Provider;
+use App\Models\User;
+use App\Services\ProviderService;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class DiditDecisionService
+{
+    public function __construct(
+        private readonly DiditDecisionExtractor $extractor,
+        private readonly ProviderService $providerService,
+    ) {}
+
+    public function handleWebhook(array $payload): ?IdentityVerification
+    {
+        $sessionId = data_get($payload, 'session_id');
+        $status    = (string) data_get($payload, 'status');
+
+        if (! is_string($sessionId) || $sessionId === '') {
+            Log::channel('didit')->warning('Webhook sem session_id; ignorado');
+
+            return null;
+        }
+
+        $user = $this->resolveUser($payload);
+
+        if (! $user) {
+            Log::channel('didit')->warning('Webhook sem usuario correspondente', [
+                'session_id'  => $sessionId,
+                'vendor_data' => data_get($payload, 'vendor_data'),
+            ]);
+
+            return null;
+        }
+
+        $decision     = (array) (data_get($payload, 'decision') ?? []);
+        $verification = $this->persist($user, $sessionId, $status, $payload, $decision);
+
+        if (! in_array($status, IdentityVerificationStatusEnum::diditTerminalStatuses(), true)) {
+            return $verification;
+        }
+
+        $this->applyDecision($user, $verification, $status, $decision);
+
+        return $verification->refresh();
+    }
+
+    //
+
+    private function persist(
+        User $user,
+        string $sessionId,
+        string $status,
+        array $payload,
+        array $decision,
+    ): IdentityVerification {
+        $verification = IdentityVerification::firstOrNew(['session_id' => $sessionId]);
+
+        $attributes = [
+            'user_id'      => $user->id,
+            'didit_status' => $status,
+            'workflow_id'  => data_get($payload, 'workflow_id') ?? $verification->workflow_id,
+        ];
+
+        if ($decision !== []) {
+            $attributes = [
+                ...$attributes,
+                ...$this->extractor->extract($decision),
+                'decision' => $this->sanitize($decision),
+            ];
+        }
+
+        if (! $verification->exists) {
+            $attributes['attempt']    = (int) $user->identityVerifications()->count() + 1;
+            $attributes['started_at'] = now();
+        }
+
+        if (in_array($status, IdentityVerificationStatusEnum::diditTerminalStatuses(), true)) {
+            $attributes['completed_at'] = now();
+        }
+
+        $verification->fill($attributes)->save();
+
+        return $verification;
+    }
+
+    private function applyDecision(
+        User $user,
+        IdentityVerification $verification,
+        string $status,
+        array $decision,
+    ): void {
+        $profile = $this->profileFor($user);
+
+        if (! $profile) {
+            return;
+        }
+
+        if ($status === 'Approved' && $this->passesAllGates($verification, $decision)) {
+            $this->approve($user, $profile, $verification);
+
+            return;
+        }
+
+        if ($status === 'Declined') {
+            $this->decline($profile);
+
+            return;
+        }
+
+        // Approved com pendencia, In Review ou Abandoned: decisao humana.
+        $this->sendToReview($profile);
+    }
+
+    private function passesAllGates(IdentityVerification $verification, array $decision): bool
+    {
+        $required = ['id_verifications', 'liveness_checks', 'face_matches'];
+
+        foreach ($required as $key) {
+            $items = $this->extractor->items($decision, $key);
+
+            if ($items === []) {
+                $this->logGate($verification, "feature ausente: {$key}");
+
+                return false;
+            }
+
+            foreach ($items as $item) {
+                if (data_get($item, 'status') !== 'Approved') {
+                    $this->logGate($verification, "feature reprovada: {$key}");
+
+                    return false;
+                }
+            }
+        }
+
+        $minLiveness  = (float) config('services.didit.liveness_min_score');
+        $minFaceMatch = (float) config('services.didit.face_match_min_score');
+
+        if ($verification->liveness_score === null || $verification->liveness_score < $minLiveness) {
+            $this->logGate($verification, 'liveness abaixo do minimo');
+
+            return false;
+        }
+
+        if ($verification->face_match_score === null || $verification->face_match_score < $minFaceMatch) {
+            $this->logGate($verification, 'face match abaixo do minimo');
+
+            return false;
+        }
+
+        if ($this->extractor->actionableWarnings($decision) !== []) {
+            $this->logGate($verification, 'warnings acionaveis presentes');
+
+            return false;
+        }
+
+        return true;
+    }
+
+    private function approve(User $user, Model $profile, IdentityVerification $verification): void
+    {
+        DB::transaction(function () use ($profile, $verification) {
+            $profile->forceFill([
+                'identity_verification_status' => IdentityVerificationStatusEnum::APPROVED->value,
+                'identity_verified_at'         => now(),
+            ]);
+
+            if ($profile instanceof Provider) {
+                $profile->document_verified = true;
+            }
+
+            if ($profile->profile_media_id) {
+                $profile->selfie_verified = true;
+            }
+
+            $profile->save();
+
+            $verification->forceFill(['completed_at' => now()])->save();
+        });
+
+        if ($profile instanceof Provider) {
+            $this->providerService->approve($profile->id);
+        }
+
+        Log::channel('didit')->info('Verificacao aprovada automaticamente', [
+            'user_id'    => $user->id,
+            'session_id' => $verification->session_id,
+        ]);
+    }
+
+    private function decline(Model $profile): void
+    {
+        $attempts = (int) $profile->identity_verification_attempts + 1;
+        $max      = (int) config('services.didit.max_attempts', 3);
+
+        $profile->forceFill([
+            'identity_verification_attempts' => $attempts,
+            'identity_verification_status'   => $attempts >= $max
+                ? IdentityVerificationStatusEnum::IN_REVIEW->value
+                : IdentityVerificationStatusEnum::DECLINED->value,
+        ])->save();
+    }
+
+    private function sendToReview(Model $profile): void
+    {
+        $profile->forceFill([
+            'identity_verification_status' => IdentityVerificationStatusEnum::IN_REVIEW->value,
+        ])->save();
+    }
+
+    private function resolveUser(array $payload): ?User
+    {
+        $vendorData = data_get($payload, 'vendor_data');
+
+        if (is_numeric($vendorData)) {
+            return User::find((int) $vendorData);
+        }
+
+        $sessionId = data_get($payload, 'session_id');
+
+        return IdentityVerification::where('session_id', $sessionId)->first()?->user;
+    }
+
+    private function profileFor(User $user): Provider|Client|null
+    {
+        return $user->type === UserTypeEnum::PROVIDER
+            ? $user->provider
+            : $user->client;
+    }
+
+    private function sanitize(array $decision): array
+    {
+        array_walk_recursive($decision, static function (&$value) {
+            if (is_string($value) && str_starts_with($value, 'http')) {
+                $value = explode('?', $value)[0];
+            }
+        });
+
+        return $decision;
+    }
+
+    private function logGate(IdentityVerification $verification, string $reason): void
+    {
+        Log::channel('didit')->info('Aprovacao automatica barrada', [
+            'session_id' => $verification->session_id,
+            'motivo'     => $reason,
+        ]);
+    }
+}

+ 156 - 0
app/Services/Didit/DiditSessionService.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace App\Services\Didit;
+
+use App\Data\Didit\Session\Parts\Request\ContactDetailsData;
+use App\Data\Didit\Session\Parts\Request\ExpectedDetailsData;
+use App\Data\Didit\Session\SessionRequestData;
+use App\Data\Didit\Session\SessionResponseData;
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\Client;
+use App\Models\IdentityVerification;
+use App\Models\Provider;
+use App\Models\User;
+use App\Services\Didit\Concerns\SendsDiditRequests;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+use RuntimeException;
+
+class DiditSessionService
+{
+    use SendsDiditRequests;
+
+    public function createSession(User $user, bool $native = false): IdentityVerification
+    {
+        $profile = $this->profileFor($user);
+
+        if (! $profile) {
+            throw new RuntimeException(__('identity_verification.profile_missing'));
+        }
+
+        if ($profile->identity_verification_status === IdentityVerificationStatusEnum::APPROVED) {
+            throw new RuntimeException(__('identity_verification.already_approved'));
+        }
+
+        $maxAttempts = (int) config('services.didit.max_attempts', 3);
+
+        if ((int) $profile->identity_verification_attempts >= $maxAttempts) {
+            throw new RuntimeException(__('identity_verification.attempts_exhausted'));
+        }
+
+        $open = IdentityVerification::query()
+            ->where('user_id', $user->id)
+            ->whereIn('didit_status', ['Not Started', 'In Progress'])
+            ->latest('id')
+            ->first();
+
+        if ($open) {
+            return $open;
+        }
+
+        $response = $this->diditRequest(
+            method:       'POST',
+            path:         '/v3/session/',
+            errorMessage: __('identity_verification.session_failed'),
+            payload:      $this->buildRequest($user, $profile, $native),
+        );
+
+        $session = SessionResponseData::fromResponse($response);
+
+        return DB::transaction(function () use ($user, $profile, $session) {
+            $attempt = (int) $profile->identity_verification_attempts + 1;
+
+            $profile->forceFill([
+                'identity_verification_status' => IdentityVerificationStatusEnum::PENDING->value,
+            ])->save();
+
+            return IdentityVerification::create([
+                'user_id'       => $user->id,
+                'session_id'    => $session->sessionId,
+                'session_token' => $session->sessionToken,
+                'workflow_id'   => $session->workflowId,
+                'didit_status'  => $session->status ?? 'Not Started',
+                'attempt'       => $attempt,
+                'started_at'    => now(),
+            ]);
+        });
+    }
+
+    public function getDecision(string $sessionId): array
+    {
+        return $this->diditRequest(
+            method:       'GET',
+            path:         "/v3/session/{$sessionId}/decision/",
+            errorMessage: __('identity_verification.decision_failed'),
+        );
+    }
+
+    public function verificationUrl(IdentityVerification $verification): string
+    {
+        return 'https://verify.didit.me/pt/session/'.$verification->session_token;
+    }
+
+    //
+
+    private function buildRequest(User $user, Model $profile, bool $native = false): SessionRequestData
+    {
+        return new SessionRequestData(
+            workflowId:      $this->workflowFor($user),
+            vendorData:      (string) $user->id,
+            callback:        $this->callbackFor($user, $native),
+            language:        'pt',
+            expectedDetails: ExpectedDetailsData::fromName(
+                fullName:    $user->name,
+                dateOfBirth: $profile->birth_date?->format('Y-m-d'),
+                document:    $profile->document,
+            ),
+            contactDetails: new ContactDetailsData(
+                email: $user->email,
+                phone: $user->phone,
+            ),
+        );
+    }
+
+    private const APP_SCHEMES = [
+        'provider' => 'br.com.diarista.provider',
+        'client'   => 'br.com.diarista.client',
+    ];
+
+    private function callbackFor(User $user, bool $native = false): ?string
+    {
+        $app = $user->type === UserTypeEnum::PROVIDER ? 'provider' : 'client';
+
+        if ($native) {
+            return self::APP_SCHEMES[$app].'://verification/done';
+        }
+
+        $callback = config('services.didit.callback_url');
+
+        if (! is_string($callback) || $callback === '') {
+            return null;
+        }
+
+        return $callback.(str_contains($callback, '?') ? '&' : '?').'app='.$app;
+    }
+
+    private function workflowFor(User $user): string
+    {
+        $workflow = $user->type === UserTypeEnum::PROVIDER
+            ? config('services.didit.workflow_provider')
+            : config('services.didit.workflow_client');
+
+        if (! is_string($workflow) || $workflow === '') {
+            throw new RuntimeException(__('identity_verification.workflow_missing'));
+        }
+
+        return $workflow;
+    }
+
+    private function profileFor(User $user): Provider|Client|null
+    {
+        return $user->type === UserTypeEnum::PROVIDER
+            ? $user->provider
+            : $user->client;
+    }
+}

+ 171 - 0
app/Services/IdentityVerificationService.php

@@ -0,0 +1,171 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Http\Resources\IdentityVerificationResource;
+use App\Models\Client;
+use App\Models\IdentityVerification;
+use App\Models\Provider;
+use App\Models\User;
+use App\Services\Didit\DiditSessionService;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+use RuntimeException;
+
+class IdentityVerificationService
+{
+    public function __construct(
+        private readonly DiditSessionService $sessionService,
+        private readonly ProviderService $providerService,
+    ) {}
+
+    public function startFor(User $user, bool $native = false): array
+    {
+        $verification = $this->sessionService->createSession($user, $native);
+
+        return [
+            'session_id'       => $verification->session_id,
+            'verification_url' => $this->sessionService->verificationUrl($verification),
+            'status'           => $verification->didit_status,
+            'attempt'          => $verification->attempt,
+            'attempts_left'    => $this->attemptsLeft($this->profileFor($user)),
+        ];
+    }
+
+    public function statusFor(User $user): array
+    {
+        $profile      = $this->profileFor($user);
+        $verification = $user->latestIdentityVerification()->first();
+
+        return [
+            'identity_verification_status' => $profile?->identity_verification_status?->value
+                ?? IdentityVerificationStatusEnum::NOT_STARTED->value,
+            'identity_verified_at' => $profile?->identity_verified_at,
+            'required'             => (bool) ($profile?->identity_verification_required ?? false),
+            'attempts_left'        => $this->attemptsLeft($profile),
+            'can_retry'            => $this->canRetry($profile),
+            'has_open_session'     => $verification !== null
+                && in_array($verification->didit_status, ['Not Started', 'In Progress'], true),
+            'last_verification'    => $verification
+                ? new IdentityVerificationResource($verification)
+                : null,
+        ];
+    }
+
+    public function pending(int $perPage, int $page): array
+    {
+        $paginator = IdentityVerification::query()
+            ->with('user')
+            ->whereIn('didit_status', ['In Review', 'Declined'])
+            ->orderBy('completed_at')
+            ->paginate(perPage: $perPage, page: $page);
+
+        return [
+            'data'  => IdentityVerificationResource::collection($paginator->items()),
+            'total' => $paginator->total(),
+            'from'  => $paginator->firstItem(),
+            'to'    => $paginator->lastItem(),
+        ];
+    }
+
+    /**
+     * Busca o laudo com as imagens frescas: as URLs do Didit sao presignadas e de
+     * vida curta, por isso nao sao persistidas e sim buscadas na hora de exibir.
+     */
+    public function findWithFreshDecision(int $id): ?array
+    {
+        $verification = IdentityVerification::with('user')->find($id);
+
+        if (! $verification) {
+            return null;
+        }
+
+        $decision = null;
+
+        try {
+            $decision = $this->sessionService->getDecision($verification->session_id);
+        } catch (\Throwable) {
+            // Laudo persistido ja basta para a decisao; so as imagens ficam indisponiveis.
+        }
+
+        return [
+            'verification'   => new IdentityVerificationResource($verification),
+            'decision'       => $decision ?? $verification->decision,
+            'decision_fresh' => $decision !== null,
+        ];
+    }
+
+    public function review(int $id, bool $approved, User $reviewer, ?string $comment): IdentityVerification
+    {
+        $verification = IdentityVerification::with('user')->find($id);
+
+        if (! $verification) {
+            throw new RuntimeException(__('identity_verification.not_found'));
+        }
+
+        $user    = $verification->user;
+        $profile = $user ? $this->profileFor($user) : null;
+
+        if (! $profile) {
+            throw new RuntimeException(__('identity_verification.profile_missing'));
+        }
+
+        DB::transaction(function () use ($verification, $profile, $approved, $reviewer, $comment) {
+            $verification->forceFill([
+                'reviewed_by'    => $reviewer->id,
+                'reviewed_at'    => now(),
+                'review_comment' => $comment,
+            ])->save();
+
+            $profile->forceFill([
+                'identity_verification_status' => $approved
+                    ? IdentityVerificationStatusEnum::APPROVED->value
+                    : IdentityVerificationStatusEnum::DECLINED->value,
+                'identity_verified_at' => $approved ? now() : null,
+            ]);
+
+            if ($approved && $profile instanceof Provider) {
+                $profile->document_verified = true;
+            }
+
+            $profile->save();
+        });
+
+        if ($approved && $profile instanceof Provider) {
+            $this->providerService->approve($profile->id);
+        }
+
+        return $verification->refresh();
+    }
+
+    //
+
+    private function attemptsLeft(?Model $profile): int
+    {
+        $max = (int) config('services.didit.max_attempts', 3);
+
+        return max(0, $max - (int) ($profile?->identity_verification_attempts ?? 0));
+    }
+
+    private function canRetry(?Model $profile): bool
+    {
+        if (! $profile) {
+            return false;
+        }
+
+        $status = $profile->identity_verification_status;
+
+        return $status instanceof IdentityVerificationStatusEnum
+            && $status->allowsNewAttempt()
+            && $this->attemptsLeft($profile) > 0;
+    }
+
+    private function profileFor(User $user): Provider|Client|null
+    {
+        return $user->type === UserTypeEnum::PROVIDER
+            ? $user->provider
+            : $user->client;
+    }
+}

+ 8 - 24
app/Services/ProviderService.php

@@ -43,7 +43,13 @@ class ProviderService
 
     public function findById(int $id): ?Provider
     {
-        return Provider::with(['user', 'profileMedia', 'documentFrontMedia', 'documentBackMedia'])->find($id);
+        return Provider::with([
+            'user',
+            'user.latestIdentityVerification',
+            'profileMedia',
+            'documentFrontMedia',
+            'documentBackMedia',
+        ])->find($id);
     }
 
     public function create(array $data): Provider
@@ -182,7 +188,7 @@ class ProviderService
 
             $provider->refresh();
 
-            $provider->load('profileMedia', 'documentFrontMedia', 'documentBackMedia');
+            $provider->load('profileMedia');
 
             $selfie = $this->mediaService->replaceFile(
                 newFile:  data_get($data, 'selfie'),
@@ -194,28 +200,6 @@ class ProviderService
 
             $provider->profile_media_id = $selfie->id;
 
-            $front = $this->mediaService->replaceFile(
-                newFile:  data_get($data, 'document_front'),
-                folder:   "provider/documentos/{$provider->id}",
-                source:   'provider_document',
-                sourceId: $provider->id,
-                old:      $provider->documentFrontMedia,
-                filename: 'frente.'.data_get($data, 'document_front')->getClientOriginalExtension(),
-            );
-
-            $provider->document_front_media_id = $front->id;
-
-            $back = $this->mediaService->replaceFile(
-                newFile:  data_get($data, 'document_back'),
-                folder:   "provider/documentos/{$provider->id}",
-                source:   'provider_document',
-                sourceId: $provider->id,
-                old:      $provider->documentBackMedia,
-                filename: 'verso.'.data_get($data, 'document_back')->getClientOriginalExtension(),
-            );
-
-            $provider->document_back_media_id = $back->id;
-
             $provider->save();
 
             Address::where('source', 'provider')->where('source_id', $provider->id)->delete();

+ 54 - 0
app/Services/WebhookService.php

@@ -4,8 +4,10 @@ namespace App\Services;
 
 use App\Models\Payment;
 use App\Models\Webhook;
+use App\Services\Didit\DiditDecisionService;
 use App\Services\Pagarme\PagarmePaymentService;
 use Illuminate\Support\Facades\Log;
+use Throwable;
 
 class WebhookService
 {
@@ -36,6 +38,58 @@ class WebhookService
     ) {
     }
 
+    public function handleDidit(array $payload): void
+    {
+        $hookId = data_get($payload, 'event_id') ?: sha1(implode('|', [
+            data_get($payload, 'session_id'),
+            data_get($payload, 'status'),
+            data_get($payload, 'webhook_type'),
+        ]));
+
+        $webhook = Webhook::query()->firstOrNew([
+            'provider' => 'didit',
+            'hook_id'  => $hookId,
+        ]);
+
+        if ($webhook->exists) {
+            $webhook->increment('attempts_count');
+
+            Log::channel('didit')->info('Webhook duplicado ignorado', ['hook_id' => $hookId]);
+
+            return;
+        }
+
+        $webhook->fill([
+            'event'          => data_get($payload, 'webhook_type'),
+            'status'         => 'received',
+            'attempts_count' => 1,
+            'payload'        => $payload,
+            'received_at'    => now(),
+        ])->save();
+
+        try {
+            $verification = app(DiditDecisionService::class)->handleWebhook($payload);
+
+            $webhook->update([
+                'status'       => $verification ? 'processed' : 'ignored',
+                'processed_at' => now(),
+            ]);
+        } catch (Throwable $e) {
+            $webhook->update([
+                'status'        => 'failed',
+                'processed_at'  => now(),
+                'error_message' => $e->getMessage(),
+            ]);
+
+            Log::channel('didit')->error('Falha ao processar webhook do Didit', [
+                'hook_id'   => $hookId,
+                'exception' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        }
+    }
+
     public function handlePagarme(array $payload): ?Payment
     {
         $event = data_get($payload, 'type');

+ 25 - 0
app/Tasks/SyncStaleDiditVerifications.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Tasks;
+
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class SyncStaleDiditVerifications
+{
+    public function __invoke(): void
+    {
+        try {
+            Artisan::call('didit:sync', ['--stale-hours' => 6, '--limit' => 100]);
+
+            Log::channel('didit')->info('Reconciliacao agendada concluida', [
+                'saida' => trim(Artisan::output()),
+            ]);
+        } catch (Throwable $e) {
+            Log::channel('didit')->error('Falha na reconciliacao agendada', [
+                'exception' => $e->getMessage(),
+            ]);
+        }
+    }
+}

+ 15 - 0
bootstrap/app.php

@@ -1,11 +1,13 @@
 <?php
 
 use App\Http\Middleware\CheckPermission;
+use App\Http\Middleware\EnsureClientIsVerified;
 use App\Http\Middleware\EnsureProviderIsAccepted;
 use App\Http\Middleware\PerformanceMonitor;
 use App\Http\Middleware\SetUserLanguage;
 use App\Tasks\DeleteExpiredTokens;
 use App\Tasks\SendPushNotificationsTask;
+use App\Tasks\SyncStaleDiditVerifications;
 use Illuminate\Console\Scheduling\Schedule;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Configuration\Exceptions;
@@ -27,6 +29,7 @@ return Application::configure(basePath: dirname(__DIR__))
             'permission'        => CheckPermission::class,
             'ability'           => CheckForAnyAbility::class,
             'provider.accepted' => EnsureProviderIsAccepted::class,
+            'client.verified'   => EnsureClientIsVerified::class,
         ]);
     })
     ->withSchedule(function (Schedule $schedule) {
@@ -43,6 +46,18 @@ return Application::configure(basePath: dirname(__DIR__))
         };
 
         $schedule->call($sendPushNotifications)->dailyAt('10:00');
+
+        $syncDiditVerifications = function () {
+            try {
+                app(SyncStaleDiditVerifications::class)();
+            } catch (Throwable $e) {
+                Log::error('Falha ao resolver SyncStaleDiditVerifications: '.$e->getMessage(), [
+                    'exception' => $e,
+                ]);
+            }
+        };
+
+        $schedule->call($syncDiditVerifications)->hourly();
     })
     ->withExceptions(function (Exceptions $exceptions) {
         //

+ 8 - 0
config/logging.php

@@ -160,6 +160,14 @@ return [
             'replace_placeholders' => true,
         ],
 
+        'didit' => [
+            'driver'               => 'daily',
+            'path'                 => storage_path('logs/integrations/didit.log'),
+            'level'                => 'debug',
+            'days'                 => 14,
+            'replace_placeholders' => true,
+        ],
+
         'google_geocoding' => [
             'driver'               => 'daily',
             'path'                 => storage_path('logs/google-geocoding/google-geocoding.log'),

+ 16 - 0
config/services.php

@@ -73,4 +73,20 @@ return [
         'platform_service_package_min_3_schedules_credit_card_fee_rate' => env('PAGARME_PLATFORM_SERVICE_PACKAGE_MIN_3_SCHEDULES_CREDIT_CARD_FEE_RATE', 0.12),
         'platform_service_package_min_3_schedules_pix_fee_rate'         => env('PAGARME_PLATFORM_SERVICE_PACKAGE_MIN_3_SCHEDULES_PIX_FEE_RATE', 0.10),
     ],
+
+    'didit' => [
+        'base_url'         => env('DIDIT_BASE_URL', 'https://verification.didit.me'),
+        'api_key'          => env('DIDIT_API_KEY'),
+        'webhook_secret'   => env('DIDIT_WEBHOOK_SECRET'),
+        'workflow_provider' => env('DIDIT_WORKFLOW_PROVIDER'),
+        'workflow_client'  => env('DIDIT_WORKFLOW_CLIENT'),
+        'callback_url'     => env('DIDIT_CALLBACK_URL'),
+        'connect_timeout'  => env('DIDIT_CONNECT_TIMEOUT', 5),
+        'timeout'          => env('DIDIT_TIMEOUT', 15),
+        'max_attempts'     => (int) env('DIDIT_MAX_ATTEMPTS', 3),
+        'webhook_tolerance' => (int) env('DIDIT_WEBHOOK_TOLERANCE', 300),
+
+        'liveness_min_score'   => (float) env('DIDIT_LIVENESS_MIN_SCORE', 80),
+        'face_match_min_score' => (float) env('DIDIT_FACE_MATCH_MIN_SCORE', 85),
+    ],
 ];

+ 54 - 0
database/migrations/2026_09_08_100001_create_identity_verifications_table.php

@@ -0,0 +1,54 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('identity_verifications', function (Blueprint $table) {
+            $table->id();
+
+            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+
+            // Identificadores da sessao no Didit
+            $table->uuid('session_id')->unique();
+            $table->string('session_token')->nullable();
+            $table->uuid('workflow_id')->nullable();
+
+            // Status literal do Didit (Not Started, In Progress, Approved, Declined, In Review, ...)
+            $table->string('didit_status')->default('Not Started')->index();
+            $table->unsignedTinyInteger('attempt')->default(1);
+
+            // Resumo por feature, para o backoffice filtrar sem abrir o JSON
+            $table->string('id_verification_status')->nullable();
+            $table->string('liveness_status')->nullable();
+            $table->string('face_match_status')->nullable();
+
+            // Escala 0-100 (normalizada na extracao)
+            $table->decimal('liveness_score', 5, 2)->nullable();
+            $table->decimal('face_match_score', 5, 2)->nullable();
+
+            $table->json('warnings')->nullable();
+            $table->json('decision')->nullable();
+
+            $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamp('reviewed_at')->nullable();
+            $table->text('review_comment')->nullable();
+
+            $table->timestamp('started_at')->nullable();
+            $table->timestamp('completed_at')->nullable();
+
+            $table->timestamps();
+
+            $table->index(['user_id', 'didit_status']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('identity_verifications');
+    }
+};

+ 46 - 0
database/migrations/2026_09_08_100002_add_identity_verification_to_providers_and_clients.php

@@ -0,0 +1,46 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        foreach (['providers', 'clients'] as $tableName) {
+            Schema::table($tableName, function (Blueprint $table) {
+                $table->string('identity_verification_status')->default('not_started')->index();
+                $table->timestamp('identity_verified_at')->nullable();
+                $table->unsignedTinyInteger('identity_verification_attempts')->default(0);
+
+                $table->boolean('identity_verification_required')->default(true);
+            });
+
+            DB::table($tableName)->update(['identity_verification_required' => false]);
+        }
+
+        Schema::table('clients', function (Blueprint $table) {
+            $table->date('birth_date')->nullable()->after('document');
+        });
+    }
+
+    public function down(): void
+    {
+        foreach (['providers', 'clients'] as $tableName) {
+            Schema::table($tableName, function (Blueprint $table) {
+                $table->dropColumn([
+                    'identity_verification_status',
+                    'identity_verified_at',
+                    'identity_verification_attempts',
+                    'identity_verification_required',
+                ]);
+            });
+        }
+
+        Schema::table('clients', function (Blueprint $table) {
+            $table->dropColumn('birth_date');
+        });
+    }
+};

+ 6 - 0
database/seeders/PermissionSeeder.php

@@ -94,6 +94,12 @@ class PermissionSeeder extends Seeder
                         'bits'        => 271,
                         'children'    => [],
                     ],
+                    [
+                        'scope'       => 'config.verification',
+                        'description' => 'Verificação de Identidade',
+                        'bits'        => 271,
+                        'children'    => [],
+                    ],
                     [
                         'scope'       => 'config.media',
                         'description' => 'Configurações de Mídia',

+ 1 - 0
database/seeders/UserTypePermissionSeeder.php

@@ -40,6 +40,7 @@ class UserTypePermissionSeeder extends Seeder
                         ['scope' => 'config.custom_schedule',          'bits' => 271],
                         ['scope' => 'config.improvement_type',         'bits' => 271],
                         ['scope' => 'config.media',                    'bits' => 271],
+                        ['scope' => 'config.verification',             'bits' => 271],
                         ['scope' => 'config.provider',                 'bits' => 271],
                         ['scope' => 'config.provider_blocked_day',     'bits' => 271],
                         ['scope' => 'config.provider_client_block',    'bits' => 271],

+ 1 - 0
lang/en/messages.php

@@ -70,6 +70,7 @@ return [
     'client_field_required_for_pagarme_order'      => 'The client must have :field to create the Pagar.me order.',
     'schedule_address_required_for_pagarme_order'  => 'The appointment address was not found for the Pagar.me order.',
     'client_field_invalid_for_pagarme_order'       => 'The client must have a valid :field to create the Pagar.me order.',
+    'client_address_location_required'              => 'Complete your profile address to request a custom service.',
     'opportunities_created'                        => '{1} :count opportunity created successfully!|[2,*] :count opportunities created successfully!',
     'proposal_sent'                                => 'Proposal sent successfully!',
     'provider_accepted'                            => 'Provider accepted successfully!',

+ 1 - 0
lang/es/messages.php

@@ -70,6 +70,7 @@ return [
     'client_field_required_for_pagarme_order'      => 'El cliente debe tener :field para crear la orden en Pagar.me.',
     'schedule_address_required_for_pagarme_order'  => 'No se encontró la dirección del servicio programado para crear la orden en Pagar.me.',
     'client_field_invalid_for_pagarme_order'       => 'El cliente debe tener un :field válido para crear la orden en Pagar.me.',
+    'client_address_location_required'              => 'Complete la dirección de su perfil para solicitar un servicio a medida.',
     'opportunities_created'                        => '{1} ¡:count oportunidad creada con éxito!|[2,*] ¡:count oportunidades creadas con éxito!',
     'proposal_sent'                                => '¡Propuesta enviada con éxito!',
     'provider_accepted'                            => '¡Prestador aceptado con éxito!',

+ 24 - 0
lang/pt/identity_verification.php

@@ -0,0 +1,24 @@
+<?php
+
+return [
+
+    'status' => [
+        'not_started' => 'Verificação não iniciada',
+        'pending'     => 'Verificação em andamento',
+        'approved'    => 'Identidade verificada',
+        'in_review'   => 'Verificação em análise',
+        'declined'    => 'Verificação não aprovada',
+        'expired'     => 'Verificação expirada',
+    ],
+
+    'profile_missing'     => 'Cadastro incompleto. Finalize o cadastro antes de verificar sua identidade.',
+    'already_approved'    => 'Sua identidade já foi verificada.',
+    'attempts_exhausted'  => 'Você atingiu o limite de tentativas. Nossa equipe irá analisar seu cadastro.',
+    'workflow_missing'    => 'Verificação de identidade indisponível no momento.',
+    'session_failed'      => 'Não foi possível iniciar a verificação de identidade',
+    'decision_failed'     => 'Não foi possível consultar o resultado da verificação',
+    'session_created'     => 'Verificação iniciada',
+    'client_not_verified' => 'Verifique sua identidade para continuar.',
+    'not_found'           => 'Verificação não encontrada',
+    'reviewed'            => 'Verificação analisada com sucesso',
+];

+ 1 - 0
lang/pt/messages.php

@@ -70,6 +70,7 @@ return [
     'client_field_required_for_pagarme_order'      => 'O cliente precisa ter :field para criar o pedido no Pagar.me.',
     'schedule_address_required_for_pagarme_order'  => 'O endereço do agendamento não foi encontrado para criar o pedido no Pagar.me.',
     'client_field_invalid_for_pagarme_order'       => 'O cliente precisa ter :field válido para criar o pedido no Pagar.me.',
+    'client_address_location_required'              => 'Complete o endereço do seu perfil para solicitar uma diária sob medida.',
     'opportunities_created'                        => '{1} :count oportunidade criada com sucesso!|[2,*] :count oportunidades criadas com sucesso!',
     'proposal_sent'                                => 'Proposta enviada com sucesso!',
     'provider_accepted'                            => 'Prestador aceito com sucesso!',

+ 54 - 0
resources/views/verification/callback.blade.php

@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<html lang="pt-BR">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Verificação enviada</title>
+    <style>
+        :root { color-scheme: light dark; }
+        body {
+            margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+            background: #f6f7f9; color: #1f2933; padding: 24px;
+        }
+        .card { max-width: 420px; text-align: center; background: #fff; border-radius: 16px; padding: 40px 28px; box-shadow: 0 8px 32px rgba(15, 23, 42, .08); }
+        .icon { width: 64px; height: 64px; margin: 0 auto 20px; border-radius: 50%; background: #e8f5e9; display: flex; align-items: center; justify-content: center; font-size: 30px; }
+        h1 { font-size: 20px; margin: 0 0 10px; }
+        p { margin: 0 0 24px; color: #52606d; line-height: 1.55; font-size: 15px; }
+        .btn {
+            display: inline-block; width: 100%; box-sizing: border-box; padding: 14px 24px;
+            border: 0; border-radius: 999px; background: #2574fc; color: #fff;
+            font-size: 16px; font-weight: 600; text-decoration: none; cursor: pointer;
+        }
+        @media (prefers-color-scheme: dark) {
+            body { background: #14181d; color: #e4e7eb; }
+            .card { background: #1f242b; box-shadow: none; }
+            p { color: #9aa5b1; }
+        }
+    </style>
+</head>
+<body>
+    <div class="card">
+        <div class="icon">✓</div>
+        <h1>Verificação enviada</h1>
+
+        @if ($scheme)
+            <p>Recebemos seus dados e estamos analisando. Estamos te levando de volta ao aplicativo...</p>
+
+            <a class="btn" id="back" href="{{ $scheme }}://verification/done">Voltar ao aplicativo</a>
+
+            <script>
+                (function () {
+                    var target = document.getElementById('back').href;
+
+                    setTimeout(function () {
+                        window.location.replace(target);
+                    }, 400);
+                })();
+            </script>
+        @else
+            <p>Recebemos seus dados e estamos analisando. Você já pode fechar esta janela e voltar ao aplicativo.</p>
+        @endif
+    </div>
+</body>
+</html>

+ 1 - 1
routes/authRoutes/schedule.php

@@ -8,7 +8,7 @@ Route::get('/schedules/grouped-by-client',     [ScheduleController::class, 'grou
 Route::get('/schedules/finished',              [ScheduleController::class, 'finished'])->middleware('permission:config.schedule,view');
 Route::get('/schedule/client-provider-blocks', [ScheduleController::class, 'clientProviderBlocks'])->middleware('permission:config.schedule,add');
 Route::get('/schedule/{id}',                   [ScheduleController::class, 'show'])->middleware('permission:config.schedule,view');
-Route::post('/schedule',                       [ScheduleController::class, 'store'])->middleware('permission:config.schedule,add');
+Route::post('/schedule',                       [ScheduleController::class, 'store'])->middleware(['permission:config.schedule,add', 'client.verified']);
 Route::put('/schedule/{id}',                   [ScheduleController::class, 'update'])->middleware('permission:config.schedule,edit');
 Route::patch('/schedule/{id}/status',          [ScheduleController::class, 'updateStatus'])->middleware('permission:config.schedule,edit');
 Route::patch('/schedule/{id}/cancel',          [ScheduleController::class, 'cancelWithReason'])->middleware('permission:config.schedule,edit');

+ 17 - 0
routes/authRoutes/verification.php

@@ -0,0 +1,17 @@
+<?php
+
+use App\Http\Controllers\VerificationController;
+use Illuminate\Support\Facades\Route;
+
+Route::post('/verification/session', [VerificationController::class, 'createSession'])
+    ->withoutMiddleware('provider.accepted')
+    ->middleware('throttle:10,1');
+
+Route::get('/verification/me', [VerificationController::class, 'me'])
+    ->withoutMiddleware('provider.accepted');
+
+// Backoffice
+Route::get('/verification/pending',       [VerificationController::class, 'pending'])->middleware('permission:config.verification,view');
+Route::get('/verification/{id}',          [VerificationController::class, 'show'])->middleware('permission:config.verification,view');
+Route::patch('/verification/{id}/approve', [VerificationController::class, 'approve'])->middleware('permission:config.verification,edit');
+Route::patch('/verification/{id}/reject',  [VerificationController::class, 'reject'])->middleware('permission:config.verification,edit');

+ 10 - 0
routes/console.php

@@ -4,6 +4,7 @@ use App\Commands\ConfirmarPagamento;
 use App\Commands\CreateCrud;
 use App\Commands\RefreshPagarmeEntities;
 use App\Commands\RefreshPermissions;
+use App\Commands\SyncDiditSessions;
 use App\Commands\TestWebsocketEvent;
 use App\Models\Provider;
 use App\Services\Pagarme\PagarmeTransferService;
@@ -82,4 +83,13 @@ Artisan::command('pagarme:recipient-balance {recipient_id} {--skip-local}', func
     $this->line(json_encode($pagarmeTransfer->getRecipientBalance($recipientId), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
 })->purpose('Consult Pagar.me recipient balance and compare it with local withdrawal balance');
 
+Artisan::command('didit:sync {session_id?} {--stale-hours=6} {--limit=50} {--dry-run}', function () {
+    $this->call(SyncDiditSessions::class, [
+        'session_id'    => $this->argument('session_id'),
+        '--stale-hours' => $this->option('stale-hours'),
+        '--limit'       => $this->option('limit'),
+        '--dry-run'     => $this->option('dry-run'),
+    ]);
+})->purpose('Reconcilia verificacoes de identidade cujo webhook do Didit nao chegou');
+
 Schedule::job(new InvalidateExpiredSchedules)->dailyAt('05:00');

+ 7 - 0
routes/noAuthRoutes/didit_callback.php

@@ -0,0 +1,7 @@
+<?php
+
+use App\Http\Controllers\VerificationController;
+use Illuminate\Support\Facades\Route;
+
+Route::get('/verification/callback', [VerificationController::class, 'callback'])
+    ->name('verification.callback');

+ 6 - 0
routes/noAuthRoutes/didit_webhook.php

@@ -0,0 +1,6 @@
+<?php
+
+use App\Http\Controllers\WebhookController;
+use Illuminate\Support\Facades\Route;
+
+Route::post('/webhooks/didit', [WebhookController::class, 'didit']);

+ 124 - 0
tests/Feature/DiditMaintenanceTest.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Enums\ApprovalStatusEnum;
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\IdentityVerification;
+use App\Models\Provider;
+use App\Models\User;
+use App\Services\Didit\DiditSessionService;
+use App\Services\PushNotificationService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Mail;
+use Mockery\MockInterface;
+use Tests\TestCase;
+
+class DiditMaintenanceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        Mail::fake();
+        $this->mock(PushNotificationService::class)->shouldReceive('sendToUser')->andReturnNull();
+    }
+
+    public function test_sync_reconcilia_verificacao_travada(): void
+    {
+        $provider     = $this->makeProvider();
+        $verification = $this->makeStuckVerification($provider->user_id);
+
+        $this->mockDecision($verification->session_id, $this->approvedDecision());
+
+        $this->artisan('didit:sync')->assertSuccessful();
+
+        $verification->refresh();
+
+        $this->assertSame('Approved', $verification->didit_status);
+        $this->assertNotNull($verification->completed_at);
+        $this->assertSame(
+            IdentityVerificationStatusEnum::APPROVED,
+            $provider->refresh()->identity_verification_status,
+        );
+    }
+
+    public function test_sync_ignora_verificacao_recente(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->makeStuckVerification($provider->user_id, startedHoursAgo: 1);
+
+        $this->mock(DiditSessionService::class)
+            ->shouldNotReceive('getDecision');
+
+        $this->artisan('didit:sync')
+            ->expectsOutputToContain('Nenhuma verificacao para reconciliar')
+            ->assertSuccessful();
+    }
+
+    public function test_dry_run_nao_altera_nada(): void
+    {
+        $provider     = $this->makeProvider();
+        $verification = $this->makeStuckVerification($provider->user_id);
+
+        $this->mock(DiditSessionService::class)
+            ->shouldNotReceive('getDecision');
+
+        $this->artisan('didit:sync', ['--dry-run' => true])->assertSuccessful();
+
+        $this->assertSame('In Progress', $verification->refresh()->didit_status);
+    }
+
+    //
+
+    private function mockDecision(string $sessionId, array $decision): MockInterface
+    {
+        return $this->mock(DiditSessionService::class, function (MockInterface $mock) use ($sessionId, $decision) {
+            $mock->shouldReceive('getDecision')->with($sessionId)->once()->andReturn($decision);
+        });
+    }
+
+    private function approvedDecision(): array
+    {
+        $decision = json_decode(
+            file_get_contents(base_path('tests/Fixtures/didit/decision_approved_cin.json')),
+            true,
+        );
+
+        $decision['status'] = 'Approved';
+
+        return $decision;
+    }
+
+    private function makeStuckVerification(int $userId, int $startedHoursAgo = 12): IdentityVerification
+    {
+        return IdentityVerification::query()->create([
+            'user_id'      => $userId,
+            'session_id'   => (string) \Illuminate\Support\Str::uuid(),
+            'didit_status' => 'In Progress',
+            'attempt'      => 1,
+            'started_at'   => now()->subHours($startedHoursAgo),
+        ]);
+    }
+
+    private function makeProvider(): Provider
+    {
+        $user = User::query()->create([
+            'name'     => 'Prestador Teste',
+            'email'    => 'prestador'.uniqid().'@teste.com',
+            'password' => 'secret',
+            'type'     => UserTypeEnum::PROVIDER->value,
+        ]);
+
+        return Provider::query()->create([
+            'user_id'         => $user->id,
+            'document'        => '06767310905',
+            'birth_date'      => '1990-01-01',
+            'approval_status' => ApprovalStatusEnum::PENDING->value,
+        ]);
+    }
+}

+ 313 - 0
tests/Feature/DiditWebhookTest.php

@@ -0,0 +1,313 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Enums\ApprovalStatusEnum;
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\IdentityVerification;
+use App\Models\Provider;
+use App\Models\User;
+use App\Models\Webhook;
+use App\Services\PushNotificationService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Mail;
+use Tests\TestCase;
+
+/**
+ * Exercita o webhook do Didit ponta a ponta usando os payloads reais capturados
+ * nas sessoes de homologacao (tests/Fixtures/didit).
+ */
+class DiditWebhookTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private const SECRET = 'segredo-de-teste-do-webhook';
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        Mail::fake();
+        $this->mock(PushNotificationService::class)->shouldReceive('sendToUser')->andReturnNull();
+
+        config([
+            'services.didit.webhook_secret'              => self::SECRET,
+            'services.didit.max_attempts'                => 3,
+            'services.didit.liveness_min_score'          => 80,
+            'services.didit.face_match_min_score'        => 85,
+        ]);
+    }
+
+    public function test_assinatura_valida_e_aceita(): void
+    {
+        $this->postWebhook($this->payload('decision_approved_cin.json', $this->makeProvider()->user_id))
+            ->assertOk();
+    }
+
+    public function test_assinatura_invalida_e_rejeitada(): void
+    {
+        $payload = $this->payload('decision_approved_cin.json', $this->makeProvider()->user_id);
+
+        $this->postJson('/api/webhooks/didit', $payload, [
+            'X-Timestamp'    => (string) time(),
+            'X-Signature-V2' => str_repeat('a', 64),
+        ])->assertStatus(401);
+    }
+
+    public function test_timestamp_fora_da_janela_e_rejeitado(): void
+    {
+        $payload = $this->payload('decision_approved_cin.json', $this->makeProvider()->user_id);
+
+        $this->postJson('/api/webhooks/didit', $payload, [
+            'X-Timestamp'    => (string) (time() - 3600),
+            'X-Signature-V2' => $this->sign($payload),
+        ])->assertStatus(401);
+    }
+
+    /** O secret pode conter varios valores (producao, dev, sandbox) separados por virgula. */
+    public function test_aceita_qualquer_um_dos_secrets_configurados(): void
+    {
+        config(['services.didit.webhook_secret' => 'outro-secret,'.self::SECRET]);
+
+        $this->postWebhook($this->payload('decision_approved_cin.json', $this->makeProvider()->user_id))
+            ->assertOk();
+    }
+
+    public function test_evento_repetido_e_ignorado(): void
+    {
+        $provider = $this->makeProvider();
+        $payload  = $this->payload('decision_approved_cin.json', $provider->user_id);
+
+        $this->postWebhook($payload)->assertOk();
+        $this->postWebhook($payload)->assertOk();
+
+        $this->assertSame(1, Webhook::where('provider', 'didit')->count());
+        $this->assertSame(2, (int) Webhook::where('provider', 'didit')->first()->attempts_count);
+    }
+
+    /** Sessao limpa: OCR, liveness e face match aprovados, sem warning acionavel. */
+    public function test_aprovado_sem_pendencia_aprova_o_prestador_automaticamente(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->postWebhook($this->payload('decision_approved_cin.json', $provider->user_id))->assertOk();
+
+        $provider->refresh();
+
+        $this->assertSame(IdentityVerificationStatusEnum::APPROVED, $provider->identity_verification_status);
+        $this->assertTrue($provider->document_verified);
+        $this->assertNotNull($provider->identity_verified_at);
+        $this->assertSame(ApprovalStatusEnum::ACCEPTED, $provider->approval_status);
+    }
+
+    /**
+     * Warnings de duplicidade chegam com log_type = information e nao podem barrar
+     * a aprovacao: eles aparecem em toda retentativa legitima.
+     */
+    public function test_warnings_informativos_nao_impedem_aprovacao(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->postWebhook($this->payload('decision_approved_cnh.json', $provider->user_id))->assertOk();
+
+        $verification = IdentityVerification::firstOrFail();
+
+        $this->assertNotEmpty($verification->warnings);
+        $this->assertEmpty($verification->actionableWarnings());
+        $this->assertSame(
+            IdentityVerificationStatusEnum::APPROVED,
+            $provider->refresh()->identity_verification_status,
+        );
+    }
+
+    /** CPF divergente: o Didit devolve In Review e o cadastro vai para a fila humana. */
+    public function test_divergencia_de_cpf_vai_para_analise_manual(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->postWebhook($this->payload('decision_in_review_cpf_mismatch.json', $provider->user_id))->assertOk();
+
+        $provider->refresh();
+
+        $this->assertSame(IdentityVerificationStatusEnum::IN_REVIEW, $provider->identity_verification_status);
+        $this->assertFalse($provider->document_verified);
+        $this->assertSame(ApprovalStatusEnum::PENDING, $provider->approval_status);
+
+        $warnings = IdentityVerification::firstOrFail()->actionableWarnings();
+
+        $this->assertContains(
+            'IDENTIFICATION_NUMBER_MISMATCH_WITH_PROVIDED',
+            array_column($warnings, 'risk'),
+        );
+    }
+
+    /** Tentativa e chance de ser avaliado: so a reprovacao do Didit consome uma. */
+    public function test_reprovacao_consome_tentativa_e_criar_sessao_nao(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->assertSame(0, (int) $provider->identity_verification_attempts);
+
+        $this->postWebhook($this->declinedPayload($provider->user_id))->assertOk();
+
+        $provider->refresh();
+
+        $this->assertSame(1, (int) $provider->identity_verification_attempts);
+        $this->assertSame(IdentityVerificationStatusEnum::DECLINED, $provider->identity_verification_status);
+    }
+
+    /** Esgotadas as tentativas, o caso deixa de ser "tente de novo" e vira analise humana. */
+    public function test_ultima_reprovacao_manda_para_analise_humana(): void
+    {
+        $provider = $this->makeProvider();
+
+        $provider->forceFill(['identity_verification_attempts' => 2])->save();
+
+        $this->postWebhook($this->declinedPayload($provider->user_id))->assertOk();
+
+        $provider->refresh();
+
+        $this->assertSame(3, (int) $provider->identity_verification_attempts);
+        $this->assertSame(IdentityVerificationStatusEnum::IN_REVIEW, $provider->identity_verification_status);
+    }
+
+    public function test_declined_marca_como_reprovado_sem_aprovar_cadastro(): void
+    {
+        $provider = $this->makeProvider();
+
+        $payload = $this->payload('decision_approved_cin.json', $provider->user_id);
+        $payload['status'] = 'Declined';
+        $payload['decision']['status'] = 'Declined';
+        $payload['decision']['id_verifications'][0]['status'] = 'Declined';
+
+        $this->postWebhook($payload)->assertOk();
+
+        $provider->refresh();
+
+        $this->assertSame(IdentityVerificationStatusEnum::DECLINED, $provider->identity_verification_status);
+        $this->assertSame(ApprovalStatusEnum::PENDING, $provider->approval_status);
+    }
+
+    /** Not Started e In Progress apenas acompanham o progresso, sem decidir nada. */
+    public function test_status_intermediario_nao_decide_nada(): void
+    {
+        $provider = $this->makeProvider();
+
+        $payload = [
+            'session_id'   => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+            'webhook_type' => 'status.updated',
+            'status'       => 'In Progress',
+            'vendor_data'  => (string) $provider->user_id,
+            'timestamp'    => time(),
+        ];
+
+        $this->postWebhook($payload)->assertOk();
+
+        $this->assertSame(
+            IdentityVerificationStatusEnum::NOT_STARTED,
+            $provider->refresh()->identity_verification_status,
+        );
+        $this->assertSame('In Progress', IdentityVerification::firstOrFail()->didit_status);
+    }
+
+    public function test_score_baixo_de_face_match_barra_a_aprovacao(): void
+    {
+        config(['services.didit.face_match_min_score' => 99]);
+
+        $provider = $this->makeProvider();
+
+        $this->postWebhook($this->payload('decision_approved_cin.json', $provider->user_id))->assertOk();
+
+        $this->assertSame(
+            IdentityVerificationStatusEnum::IN_REVIEW,
+            $provider->refresh()->identity_verification_status,
+        );
+    }
+
+    //
+
+    private function declinedPayload(int $userId): array
+    {
+        $payload = $this->payload('decision_approved_cin.json', $userId);
+
+        $payload['event_id']                                 = 'evt-declined-'.$userId;
+        $payload['status']                                   = 'Declined';
+        $payload['decision']['status']                       = 'Declined';
+        $payload['decision']['id_verifications'][0]['status'] = 'Declined';
+
+        return $payload;
+    }
+
+    private function postWebhook(array $payload)
+    {
+        return $this->postJson('/api/webhooks/didit', $payload, [
+            'X-Timestamp'    => (string) time(),
+            'X-Signature-V2' => $this->sign($payload),
+        ]);
+    }
+
+    private function sign(array $payload): string
+    {
+        return hash_hmac('sha256', $this->canonical($payload), self::SECRET);
+    }
+
+    /** Mesma canonicalizacao do Didit: chaves ordenadas, unicode e barras sem escape. */
+    private function canonical(array $payload): string
+    {
+        $decoded = json_decode(json_encode($payload), false);
+
+        $sort = function ($value) use (&$sort) {
+            if (is_array($value)) {
+                return array_map($sort, $value);
+            }
+
+            if ($value instanceof \stdClass) {
+                $data = get_object_vars($value);
+                ksort($data, SORT_STRING);
+
+                return (object) array_map($sort, $data);
+            }
+
+            if (is_float($value) && floor($value) === $value) {
+                return (int) $value;
+            }
+
+            return $value;
+        };
+
+        return json_encode($sort($decoded), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+    }
+
+    private function payload(string $fixture, int $userId): array
+    {
+        $decision = json_decode(file_get_contents(base_path("tests/Fixtures/didit/{$fixture}")), true);
+
+        return [
+            'event_id'     => 'evt-'.$fixture,
+            'session_id'   => $decision['session_id'] ?? '11111111-2222-3333-4444-555555555555',
+            'webhook_type' => 'status.updated',
+            'status'       => $decision['status'],
+            'vendor_data'  => (string) $userId,
+            'timestamp'    => time(),
+            'decision'     => $decision,
+        ];
+    }
+
+    private function makeProvider(): Provider
+    {
+        $user = User::query()->create([
+            'name'     => 'Prestador Teste',
+            'email'    => 'prestador'.uniqid().'@teste.com',
+            'password' => 'secret',
+            'type'     => UserTypeEnum::PROVIDER->value,
+        ]);
+
+        return Provider::query()->create([
+            'user_id'         => $user->id,
+            'document'        => '06767310905',
+            'birth_date'      => '1990-01-01',
+            'approval_status' => ApprovalStatusEnum::PENDING->value,
+        ]);
+    }
+}

+ 338 - 0
tests/Fixtures/didit/decision_approved_cin.json

@@ -0,0 +1,338 @@
+{
+  "session_id": "00000000-0000-4000-8000-000000000001",
+  "session_kind": "user",
+  "shared_from_session": null,
+  "session_number": 2,
+  "session_url": "https://example.test/session",
+  "status": "Approved",
+  "status_override": null,
+  "workflow_id": "a3a29685-6e02-47e4-bebe-6bcb2b5dc009",
+  "features": [
+    "ID_VERIFICATION",
+    "LIVENESS",
+    "FACE_MATCH",
+    "IP_ANALYSIS"
+  ],
+  "vendor_data": "1",
+  "metadata": null,
+  "callback": null,
+  "id_verifications": [
+    {
+      "status": "Approved",
+      "document_type": "Identity Card",
+      "document_subtype": "ID_CARD_GENERIC",
+      "document_number": "11122233396",
+      "personal_number": "11122233396",
+      "portrait_image": "https://example.test/didit/portrait_image.jpg",
+      "front_image": "https://example.test/didit/front_image.jpg",
+      "front_video": null,
+      "back_image": "https://example.test/didit/back_image.jpg",
+      "back_video": null,
+      "full_front_image": "https://example.test/didit/front_image.jpg",
+      "full_back_image": "https://example.test/didit/back_image.jpg",
+      "front_image_camera_front": null,
+      "back_image_camera_front": null,
+      "front_image_camera_front_face_match_score": null,
+      "back_image_camera_front_face_match_score": null,
+      "front_image_quality_score": {
+        "focus_score": 100.0,
+        "model_quality": {
+          "time_ms": 387.1,
+          "yolo_prob": 0.9974,
+          "convnext_prob": 1.0,
+          "ensemble_prob": 0.9988,
+          "document_occlusion": {
+            "detected": false,
+            "covered_fields": []
+          }
+        },
+        "overall_score": 90.6,
+        "brightness_issue": "ok",
+        "brightness_score": 97.3,
+        "resolution_score": 65.6,
+        "is_document_fully_visible": true
+      },
+      "back_image_quality_score": {
+        "focus_score": 100.0,
+        "model_quality": {
+          "time_ms": 366.3,
+          "yolo_prob": 0.9748,
+          "convnext_prob": 0.9999,
+          "ensemble_prob": 0.9886,
+          "document_occlusion": {
+            "detected": false,
+            "covered_fields": []
+          }
+        },
+        "overall_score": 85.0,
+        "brightness_issue": "ok",
+        "brightness_score": 78.6,
+        "resolution_score": 65.6,
+        "is_document_fully_visible": true
+      },
+      "date_of_birth": "1990-01-15",
+      "age": 26,
+      "expiration_date": "2035-03-12",
+      "date_of_issue": "2025-03-12",
+      "issuing_state": "BRA",
+      "issuing_state_name": "Brazil",
+      "region": null,
+      "first_name": "Maria",
+      "last_name": "Aparecida Souza",
+      "full_name": "Maria Aparecida Souza",
+      "gender": "M",
+      "address": null,
+      "formatted_address": null,
+      "place_of_birth": "Cidade Exemplo/PR",
+      "marital_status": "UNKNOWN",
+      "nationality": "BRA",
+      "extra_fields": {
+        "tax_number": "11122233396",
+        "first_surname": "Aparecida",
+        "second_surname": "Souza",
+        "reference_number": "C00000000000"
+      },
+      "mrz": null,
+      "extracted_data": null,
+      "barcodes": [
+        {
+          "data": "06792664209854",
+          "side": "back",
+          "type": "DATA_BAR",
+          "data_raw": "",
+          "position": [
+            [
+              522,
+              563
+            ],
+            [
+              905,
+              194
+            ],
+            [
+              905,
+              254
+            ],
+            [
+              522,
+              671
+            ]
+          ]
+        }
+      ],
+      "parsed_address": null,
+      "extra_files": [],
+      "warnings": [
+        {
+          "feature": "ID_VERIFICATION",
+          "risk": "QR_NOT_DETECTED",
+          "additional_data": null,
+          "log_type": "information",
+          "short_description": "QR not detected",
+          "long_description": "The system couldn't find or read the QR code on the document. This could be due to poor image quality or an unsupported document type.",
+          "node_id": "feature_ocr"
+        }
+      ],
+      "node_id": "feature_ocr",
+      "matches": [],
+      "verification_method": "document",
+      "assurance": "documentary",
+      "wallet_provider": null,
+      "fallback_from": null,
+      "id_lookup": null,
+      "wallet_verification": null
+    }
+  ],
+  "nfc_verifications": null,
+  "nfc_skip_reason": null,
+  "liveness_checks": [
+    {
+      "status": "Approved",
+      "method": "PASSIVE",
+      "score": 100.0,
+      "reference_image": "https://example.test/didit/reference_image.jpg",
+      "video_url": "https://example.test/didit/video_url.jpg",
+      "age_estimation": 29.61,
+      "matches": [],
+      "warnings": [],
+      "face_quality": 100.0,
+      "face_luminance": 45.83,
+      "node_id": "feature_liveness"
+    }
+  ],
+  "face_matches": [
+    {
+      "status": "Approved",
+      "score": 97.83,
+      "source_image_session_id": null,
+      "source_image": "https://example.test/didit/source_image.jpg",
+      "target_image": "https://example.test/didit/target_image.jpg",
+      "warnings": [],
+      "node_id": "feature_face_match",
+      "face_coverage": {
+        "eyes": {
+          "covered": null,
+          "calibrated": false,
+          "visibility_score": 100.0
+        },
+        "mouth": {
+          "covered": null,
+          "calibrated": false,
+          "visibility_score": 100.0
+        },
+        "face": {
+          "covered": null,
+          "calibrated": false,
+          "visibility_score": 100.0
+        }
+      }
+    }
+  ],
+  "poa_verifications": null,
+  "phone_verifications": null,
+  "email_verifications": null,
+  "aml_screenings": null,
+  "ip_analyses": [
+    {
+      "status": "Approved",
+      "node_id": "feature_ip_analysis",
+      "device_brand": "Generic_Android",
+      "device_model": "K",
+      "browser_family": "Chrome Mobile",
+      "browser_version": "152.0.0",
+      "os_family": "Android",
+      "os_version": "10",
+      "platform": "Linux armv81",
+      "device_fingerprint": "fingerprint-ficticio",
+      "user_agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Mobile Safari/537.36",
+      "accept_language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
+      "session_identifier": null,
+      "session_age_ms": null,
+      "coords_accuracy": null,
+      "raw_device_data": {
+        "screen": {
+          "width": 444,
+          "height": 985,
+          "availWidth": 444,
+          "colorDepth": 24,
+          "pixelDepth": 24,
+          "availHeight": 985
+        },
+        "uaData": {
+          "model": "moto g56 5G",
+          "brands": [
+            {
+              "brand": "Chromium",
+              "version": "152"
+            },
+            {
+              "brand": "Not?A_Brand",
+              "version": "24"
+            },
+            {
+              "brand": "Google Chrome",
+              "version": "152"
+            }
+          ],
+          "mobile": true,
+          "platform": "Android",
+          "architecture": "",
+          "uaFullVersion": "152.0.7977.76",
+          "platformVersion": "16.0.0"
+        },
+        "battery": {
+          "level": 1,
+          "charging": false,
+          "chargingTime": null,
+          "dischargingTime": null
+        },
+        "language": "pt-BR",
+        "platform": "Linux armv81",
+        "integrity": {
+          "capture": {
+            "externalDevice": null,
+            "matchedPatterns": [],
+            "videoInputCount": 1,
+            "virtualCameraSuspected": null
+          },
+          "platform": "web",
+          "security": {},
+          "environment": {
+            "automationDetected": false,
+            "screenCaptureActive": null
+          },
+          "collected_at": "2026-09-08T18:05:52.589Z",
+          "schema_version": 1
+        },
+        "languages": [
+          "pt-BR",
+          "pt",
+          "en-US",
+          "en"
+        ],
+        "userAgent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Mobile Safari/537.36",
+        "deviceMemory": 8,
+        "touchSupport": true,
+        "maxTouchPoints": 5,
+        "devicePixelRatio": 2.4375,
+        "hardwareConcurrency": 8
+      },
+      "ip_country": "Brazil",
+      "ip_country_code": "BR",
+      "ip_state": "Parana",
+      "ip_city": "Marechal Cândido Rondon",
+      "latitude": 0,
+      "longitude": 0,
+      "ip_address": "203.0.113.10",
+      "isp": "Opcao Telecom",
+      "organization": "Opcao Telecom",
+      "is_vpn_or_tor": false,
+      "is_data_center": false,
+      "time_zone": "America/Sao_Paulo",
+      "time_zone_offset": "-0300",
+      "ip": {
+        "location": {
+          "latitude": 0,
+          "longitude": 0
+        },
+        "distance_from_id_document": null,
+        "distance_from_poa_document": null
+      },
+      "id_document": {
+        "location": null,
+        "distance_from_ip": null,
+        "distance_from_poa_document": null
+      },
+      "poa_document": {
+        "location": null,
+        "distance_from_ip": null,
+        "distance_from_id_document": null
+      },
+      "warnings": [],
+      "matches": []
+    }
+  ],
+  "database_validations": null,
+  "database_validation_not_performed_reason": {
+    "code": "feature_not_unlocked",
+    "issuing_state": "BRA",
+    "configured_services": [
+      "Brazil - CNH QR code + face match (Datavalid)",
+      "Brazil - CPF status check",
+      "Brazil - CPF + face match (Datavalid)",
+      "Brazil Residential",
+      "Brazil Tax Registration (CPF/CNPJ)",
+      "BRA - Unico IDCloud (CPF + selfie)"
+    ],
+    "message": "Database validation did not run: it is locked until the organization's first paid top-up. No database was queried and nothing was billed for it."
+  },
+  "questionnaire_responses": null,
+  "document_ai_documents": null,
+  "reviews": [],
+  "contact_details": null,
+  "expected_details": null,
+  "environment": "live",
+  "sandbox_scenario": null,
+  "created_at": "2026-09-08T18:04:39.640768Z",
+  "expires_at": "2026-09-15T18:04:39.633322Z"
+}

+ 471 - 0
tests/Fixtures/didit/decision_approved_cnh.json

@@ -0,0 +1,471 @@
+{
+  "session_id": "00000000-0000-4000-8000-000000000002",
+  "session_kind": "user",
+  "shared_from_session": null,
+  "session_number": 3,
+  "session_url": "https://example.test/session",
+  "status": "Approved",
+  "status_override": null,
+  "workflow_id": "a3a29685-6e02-47e4-bebe-6bcb2b5dc009",
+  "features": [
+    "ID_VERIFICATION",
+    "LIVENESS",
+    "FACE_MATCH",
+    "IP_ANALYSIS"
+  ],
+  "vendor_data": "1",
+  "metadata": null,
+  "callback": null,
+  "id_verifications": [
+    {
+      "status": "Approved",
+      "document_type": "Driver's License",
+      "document_subtype": "DRIVER_LICENSE_GENERIC",
+      "document_number": "11122233396",
+      "personal_number": "11122233396",
+      "portrait_image": "https://example.test/didit/portrait_image.jpg",
+      "front_image": "https://example.test/didit/front_image.jpg",
+      "front_video": null,
+      "back_image": "https://example.test/didit/back_image.jpg",
+      "back_video": null,
+      "full_front_image": "https://example.test/didit/front_image.jpg",
+      "full_back_image": "https://example.test/didit/back_image.jpg",
+      "front_image_camera_front": null,
+      "back_image_camera_front": null,
+      "front_image_camera_front_face_match_score": null,
+      "back_image_camera_front_face_match_score": null,
+      "front_image_quality_score": {
+        "focus_score": 100.0,
+        "model_quality": {
+          "time_ms": 650.1,
+          "yolo_prob": 0.9635,
+          "convnext_prob": 0.9999,
+          "ensemble_prob": 0.9835,
+          "document_occlusion": {
+            "detected": false,
+            "covered_fields": []
+          }
+        },
+        "overall_score": 86.7,
+        "brightness_issue": "ok",
+        "brightness_score": 84.3,
+        "resolution_score": 65.6,
+        "is_document_fully_visible": true
+      },
+      "back_image_quality_score": {
+        "focus_score": 74.9,
+        "model_quality": {
+          "time_ms": 625.6,
+          "yolo_prob": 0.9692,
+          "convnext_prob": 1.0,
+          "ensemble_prob": 0.9861,
+          "document_occlusion": {
+            "detected": false,
+            "covered_fields": []
+          }
+        },
+        "overall_score": 83.5,
+        "brightness_issue": "ok",
+        "brightness_score": 94.8,
+        "resolution_score": 85.6,
+        "is_document_fully_visible": null
+      },
+      "date_of_birth": "1990-01-15",
+      "age": 26,
+      "expiration_date": "2034-11-19",
+      "date_of_issue": "2024-12-26",
+      "issuing_state": "BRA",
+      "issuing_state_name": "Brazil",
+      "region": null,
+      "first_name": "Maria",
+      "last_name": "Aparecida Souza",
+      "full_name": "Maria Aparecida Souza",
+      "gender": "U",
+      "address": null,
+      "formatted_address": null,
+      "place_of_birth": "Cidade Exemplo/PR",
+      "marital_status": "UNKNOWN",
+      "nationality": "BRA",
+      "extra_fields": {
+        "tax_number": "11122233396",
+        "first_surname": "Aparecida",
+        "place_of_issue": "CURITIBA, PR",
+        "second_surname": "Souza",
+        "first_issue_date": "2018-09-14",
+        "reference_number": "C00000000000",
+        "dl_class_code_a_to": "2034-11-19",
+        "dl_class_code_b_to": "2034-11-19"
+      },
+      "mrz": null,
+      "extracted_data": null,
+      "barcodes": [],
+      "parsed_address": null,
+      "extra_files": [],
+      "warnings": [
+        {
+          "feature": "ID_VERIFICATION",
+          "risk": "POSSIBLE_DUPLICATED_USER",
+          "additional_data": {
+            "api_service": null,
+            "duplicated_session_id": "00000000-0000-4000-8000-000000000001",
+            "duplicated_session_number": 2
+          },
+          "log_type": "information",
+          "short_description": "Possible duplicated user from other session",
+          "long_description": "The system identified a potential duplicate user with documents from another session, requiring further investigation.",
+          "node_id": "feature_ocr"
+        }
+      ],
+      "node_id": "feature_ocr",
+      "matches": [
+        {
+          "status": "Approved",
+          "session_id": "00000000-0000-4000-8000-000000000001",
+          "api_service": null,
+          "vendor_data": "1",
+          "user_details": {
+            "name": "Maria Aparecida Souza",
+            "document_type": "ID",
+            "document_number": "11122233396"
+          },
+          "is_blocklisted": false,
+          "session_number": 2,
+          "front_image_url": "https://example.test/didit/front_image_url.jpg",
+          "verification_date": "2026-09-08T18:04:39Z"
+        }
+      ],
+      "verification_method": "document",
+      "assurance": "documentary",
+      "wallet_provider": null,
+      "fallback_from": null,
+      "id_lookup": null,
+      "wallet_verification": null
+    }
+  ],
+  "nfc_verifications": null,
+  "nfc_skip_reason": null,
+  "liveness_checks": [
+    {
+      "status": "Approved",
+      "method": "PASSIVE",
+      "score": 100.0,
+      "reference_image": "https://example.test/didit/reference_image.jpg",
+      "video_url": "https://example.test/didit/video_url.jpg",
+      "age_estimation": 29.05,
+      "matches": [
+        {
+          "source": "session",
+          "status": "Approved",
+          "session_id": "00000000-0000-4000-8000-000000000001",
+          "api_service": null,
+          "vendor_data": "1",
+          "user_details": {
+            "full_name": "Maria Aparecida Souza",
+            "document_type": "ID",
+            "document_number": "11122233396"
+          },
+          "is_allowlisted": false,
+          "is_blocklisted": false,
+          "session_number": 2,
+          "vendor_user_id": "9954d867-632b-42d7-9d84-e237b950b22a",
+          "match_image_url": "https://example.test/didit/match_image_url.jpg",
+          "verification_date": "2026-09-08T18:04:39Z",
+          "biometric_template_id": null,
+          "similarity_percentage": 77.93108224868774
+        }
+      ],
+      "warnings": [
+        {
+          "feature": "LIVENESS",
+          "risk": "DUPLICATED_FACE",
+          "additional_data": {
+            "api_service": null,
+            "duplicated_session_id": "00000000-0000-4000-8000-000000000001",
+            "duplicated_session_number": 2
+          },
+          "log_type": "information",
+          "short_description": "Duplicated face from other approved session",
+          "long_description": "The system identified a duplicated face from another approved session, requiring further investigation.",
+          "node_id": "feature_liveness"
+        }
+      ],
+      "face_quality": 100.0,
+      "face_luminance": 53.3,
+      "node_id": "feature_liveness"
+    }
+  ],
+  "face_matches": [
+    {
+      "status": "Approved",
+      "score": 95.83,
+      "source_image_session_id": null,
+      "source_image": "https://example.test/didit/source_image.jpg",
+      "target_image": "https://example.test/didit/target_image.jpg",
+      "warnings": [],
+      "node_id": "feature_face_match",
+      "face_coverage": {
+        "eyes": {
+          "covered": null,
+          "calibrated": false,
+          "visibility_score": 100.0
+        },
+        "mouth": {
+          "covered": null,
+          "calibrated": false,
+          "visibility_score": 100.0
+        },
+        "face": {
+          "covered": null,
+          "calibrated": false,
+          "visibility_score": 100.0
+        }
+      }
+    }
+  ],
+  "poa_verifications": null,
+  "phone_verifications": null,
+  "email_verifications": null,
+  "aml_screenings": null,
+  "ip_analyses": [
+    {
+      "status": "Approved",
+      "node_id": "feature_ip_analysis",
+      "device_brand": "Generic_Android",
+      "device_model": "K",
+      "browser_family": "Chrome Mobile",
+      "browser_version": "152.0.0",
+      "os_family": "Android",
+      "os_version": "10",
+      "platform": "Linux armv81",
+      "device_fingerprint": "fingerprint-ficticio",
+      "user_agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Mobile Safari/537.36",
+      "accept_language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
+      "session_identifier": null,
+      "session_age_ms": null,
+      "coords_accuracy": null,
+      "raw_device_data": {
+        "screen": {
+          "width": 444,
+          "height": 985,
+          "availWidth": 444,
+          "colorDepth": 24,
+          "pixelDepth": 24,
+          "availHeight": 985
+        },
+        "uaData": {
+          "model": "moto g56 5G",
+          "brands": [
+            {
+              "brand": "Chromium",
+              "version": "152"
+            },
+            {
+              "brand": "Not?A_Brand",
+              "version": "24"
+            },
+            {
+              "brand": "Google Chrome",
+              "version": "152"
+            }
+          ],
+          "mobile": true,
+          "platform": "Android",
+          "architecture": "",
+          "uaFullVersion": "152.0.7977.76",
+          "platformVersion": "16.0.0"
+        },
+        "battery": {
+          "level": 1,
+          "charging": false,
+          "chargingTime": null,
+          "dischargingTime": 32577
+        },
+        "language": "pt-BR",
+        "platform": "Linux armv81",
+        "integrity": {
+          "capture": {
+            "externalDevice": null,
+            "matchedPatterns": [],
+            "videoInputCount": 1,
+            "virtualCameraSuspected": null
+          },
+          "platform": "web",
+          "security": {},
+          "environment": {
+            "automationDetected": false,
+            "screenCaptureActive": null
+          },
+          "collected_at": "2026-09-08T18:13:36.162Z",
+          "schema_version": 1
+        },
+        "languages": [
+          "pt-BR",
+          "pt",
+          "en-US",
+          "en"
+        ],
+        "userAgent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Mobile Safari/537.36",
+        "deviceMemory": 8,
+        "touchSupport": true,
+        "maxTouchPoints": 5,
+        "devicePixelRatio": 2.4375,
+        "hardwareConcurrency": 8
+      },
+      "ip_country": "Brazil",
+      "ip_country_code": "BR",
+      "ip_state": "Parana",
+      "ip_city": "Marechal Cândido Rondon",
+      "latitude": 0,
+      "longitude": 0,
+      "ip_address": "203.0.113.10",
+      "isp": "Opcao Telecom",
+      "organization": "Opcao Telecom",
+      "is_vpn_or_tor": false,
+      "is_data_center": false,
+      "time_zone": "America/Sao_Paulo",
+      "time_zone_offset": "-0300",
+      "ip": {
+        "location": {
+          "latitude": 0,
+          "longitude": 0
+        },
+        "distance_from_id_document": null,
+        "distance_from_poa_document": null
+      },
+      "id_document": {
+        "location": null,
+        "distance_from_ip": null,
+        "distance_from_poa_document": null
+      },
+      "poa_document": {
+        "location": null,
+        "distance_from_ip": null,
+        "distance_from_id_document": null
+      },
+      "warnings": [
+        {
+          "feature": "LOCATION",
+          "risk": "DUPLICATED_DEVICE_FINGERPRINT",
+          "additional_data": {
+            "api_service": null,
+            "match_source": "persistent_id",
+            "duplicated_session_id": "00000000-0000-4000-8000-000000000001",
+            "duplicated_session_number": 2
+          },
+          "log_type": "information",
+          "short_description": "Duplicated device fingerprint from another session",
+          "long_description": "The same device fingerprint was detected in another session with a different vendor_data, which may indicate multiple identities verified from the same device.",
+          "node_id": "feature_ip_analysis"
+        },
+        {
+          "feature": "LOCATION",
+          "risk": "DUPLICATED_IP_ADDRESS",
+          "additional_data": {
+            "api_service": null,
+            "duplicated_session_id": "00000000-0000-4000-8000-000000000001",
+            "duplicated_session_number": 2
+          },
+          "log_type": "information",
+          "short_description": "Duplicated IP address from another session",
+          "long_description": "The same IP address was used in another session with a different vendor_data, which may indicate multiple users sharing the same network or a potential fraud pattern.",
+          "node_id": "feature_ip_analysis"
+        }
+      ],
+      "matches": [
+        {
+          "source": "session",
+          "status": "Approved",
+          "confidence": 0.0,
+          "match_mode": "co_occurrence",
+          "match_type": "ip_address",
+          "session_id": "00000000-0000-4000-8000-000000000001",
+          "api_service": null,
+          "device_info": {
+            "platform": "Linux armv81",
+            "os_family": "Android",
+            "device_brand": "Generic_Android",
+            "device_model": "K",
+            "browser_family": "Chrome Mobile",
+            "device_fingerprint": "fingerprint-ficticio"
+          },
+          "vendor_data": "1",
+          "match_source": "ip_address",
+          "location_info": {
+            "ip_city": "Marechal Cândido Rondon",
+            "ip_state": "Parana",
+            "ip_address": "203.0.113.10",
+            "ip_country": "Brazil",
+            "is_vpn_or_tor": false,
+            "is_data_center": false,
+            "ip_country_code": "BR"
+          },
+          "matched_value": "203.0.113.10",
+          "is_blocklisted": false,
+          "session_number": 2,
+          "verification_date": "2026-09-08T18:04:39Z"
+        },
+        {
+          "source": "session",
+          "status": "Approved",
+          "confidence": 1.0,
+          "match_mode": "deterministic",
+          "match_type": "device_fingerprint",
+          "session_id": "00000000-0000-4000-8000-000000000001",
+          "api_service": null,
+          "device_info": {
+            "platform": "Linux armv81",
+            "os_family": "Android",
+            "device_brand": "Generic_Android",
+            "device_model": "K",
+            "browser_family": "Chrome Mobile",
+            "device_fingerprint": "fingerprint-ficticio"
+          },
+          "vendor_data": "1",
+          "match_source": "persistent_id",
+          "location_info": {
+            "ip_city": "Marechal Cândido Rondon",
+            "ip_state": "Parana",
+            "ip_address": "203.0.113.10",
+            "ip_country": "Brazil",
+            "is_vpn_or_tor": false,
+            "is_data_center": false,
+            "ip_country_code": "BR"
+          },
+          "matched_value": "valor-ficticio",
+          "is_blocklisted": false,
+          "session_number": 2,
+          "verification_date": "2026-09-08T18:04:39Z"
+        }
+      ]
+    }
+  ],
+  "database_validations": null,
+  "database_validation_not_performed_reason": {
+    "code": "feature_not_unlocked",
+    "issuing_state": "BRA",
+    "configured_services": [
+      "Brazil - CNH QR code + face match (Datavalid)",
+      "Brazil - CPF status check",
+      "Brazil - CPF + face match (Datavalid)",
+      "Brazil Residential",
+      "Brazil Tax Registration (CPF/CNPJ)",
+      "BRA - Unico IDCloud (CPF + selfie)"
+    ],
+    "message": "Database validation did not run: it is locked until the organization's first paid top-up. No database was queried and nothing was billed for it."
+  },
+  "questionnaire_responses": null,
+  "document_ai_documents": null,
+  "reviews": [],
+  "contact_details": null,
+  "expected_details": {
+    "last_name": "Aparecida Souza",
+    "first_name": "Maria",
+    "id_country": "BRA",
+    "date_of_birth": "1990-01-15",
+    "identification_number": "11122233396"
+  },
+  "environment": "live",
+  "sandbox_scenario": null,
+  "created_at": "2026-09-08T18:12:20.402916Z",
+  "expires_at": "2026-09-15T18:12:20.389320Z"
+}

+ 573 - 0
tests/Fixtures/didit/decision_in_review_cpf_mismatch.json

@@ -0,0 +1,573 @@
+{
+  "session_id": "00000000-0000-4000-8000-000000000003",
+  "session_kind": "user",
+  "shared_from_session": null,
+  "session_number": 4,
+  "session_url": "https://example.test/session",
+  "status": "In Review",
+  "status_override": null,
+  "workflow_id": "a3a29685-6e02-47e4-bebe-6bcb2b5dc009",
+  "features": [
+    "ID_VERIFICATION",
+    "LIVENESS",
+    "FACE_MATCH",
+    "IP_ANALYSIS"
+  ],
+  "vendor_data": "1",
+  "metadata": null,
+  "callback": null,
+  "id_verifications": [
+    {
+      "status": "In Review",
+      "document_type": "Driver's License",
+      "document_subtype": "DRIVER_LICENSE_GENERIC",
+      "document_number": "11122233396",
+      "personal_number": "11122233396",
+      "portrait_image": "https://example.test/didit/portrait_image.jpg",
+      "front_image": "https://example.test/didit/front_image.jpg",
+      "front_video": null,
+      "back_image": null,
+      "back_video": null,
+      "full_front_image": "https://example.test/didit/front_image.jpg",
+      "full_back_image": null,
+      "front_image_camera_front": null,
+      "back_image_camera_front": null,
+      "front_image_camera_front_face_match_score": null,
+      "back_image_camera_front_face_match_score": null,
+      "front_image_quality_score": {
+        "focus_score": 100.0,
+        "model_quality": {
+          "time_ms": 367.3,
+          "yolo_prob": 0.6798,
+          "convnext_prob": 0.9066,
+          "ensemble_prob": 0.8045,
+          "document_occlusion": {
+            "detected": false,
+            "covered_fields": []
+          }
+        },
+        "overall_score": 89.2,
+        "brightness_issue": "ok",
+        "brightness_score": 92.8,
+        "resolution_score": 65.6,
+        "is_document_fully_visible": true
+      },
+      "back_image_quality_score": null,
+      "date_of_birth": "1990-01-15",
+      "age": 26,
+      "expiration_date": "2034-11-19",
+      "date_of_issue": "2024-12-25",
+      "issuing_state": "BRA",
+      "issuing_state_name": "Brazil",
+      "region": null,
+      "first_name": "Maria",
+      "last_name": "Aparecida Souza",
+      "full_name": "Maria Aparecida Souza",
+      "gender": "U",
+      "address": null,
+      "formatted_address": null,
+      "place_of_birth": "Cidade Exemplo/PR",
+      "marital_status": "UNKNOWN",
+      "nationality": "BRA",
+      "extra_fields": {
+        "tax_number": "11122233396",
+        "first_surname": "Aparecida",
+        "second_surname": "Souza",
+        "first_issue_date": "2018-09-14"
+      },
+      "mrz": null,
+      "extracted_data": null,
+      "barcodes": [],
+      "parsed_address": null,
+      "extra_files": [],
+      "warnings": [
+        {
+          "feature": "ID_VERIFICATION",
+          "risk": "IDENTIFICATION_NUMBER_MISMATCH_WITH_PROVIDED",
+          "additional_data": {
+            "expected_identification_number": "11122233300",
+            "extracted_identification_numbers": [
+              "11122233396"
+            ]
+          },
+          "log_type": "warning",
+          "short_description": "Identification number mismatch with provided information",
+          "long_description": "The provided identification number in `expected_details` doesn't match the document number, personal number, or tax number extracted from the document.",
+          "node_id": "feature_ocr"
+        },
+        {
+          "feature": "ID_VERIFICATION",
+          "risk": "POSSIBLE_DUPLICATED_USER",
+          "additional_data": {
+            "api_service": null,
+            "duplicated_session_id": "00000000-0000-4000-8000-000000000002",
+            "duplicated_session_number": 3
+          },
+          "log_type": "information",
+          "short_description": "Possible duplicated user from other session",
+          "long_description": "The system identified a potential duplicate user with documents from another session, requiring further investigation.",
+          "node_id": "feature_ocr"
+        }
+      ],
+      "node_id": "feature_ocr",
+      "matches": [
+        {
+          "status": "Approved",
+          "session_id": "00000000-0000-4000-8000-000000000002",
+          "api_service": null,
+          "vendor_data": "1",
+          "user_details": {
+            "name": "Maria Aparecida Souza",
+            "document_type": "DL",
+            "document_number": "11122233396"
+          },
+          "is_blocklisted": false,
+          "session_number": 3,
+          "front_image_url": "https://example.test/didit/front_image_url.jpg",
+          "verification_date": "2026-09-08T18:12:20Z"
+        },
+        {
+          "status": "Approved",
+          "session_id": "00000000-0000-4000-8000-000000000001",
+          "api_service": null,
+          "vendor_data": "1",
+          "user_details": {
+            "name": "Maria Aparecida Souza",
+            "document_type": "ID",
+            "document_number": "11122233396"
+          },
+          "is_blocklisted": false,
+          "session_number": 2,
+          "front_image_url": "https://example.test/didit/front_image_url.jpg",
+          "verification_date": "2026-09-08T18:04:39Z"
+        }
+      ],
+      "verification_method": "document",
+      "assurance": "documentary",
+      "wallet_provider": null,
+      "fallback_from": null,
+      "id_lookup": null,
+      "wallet_verification": null
+    }
+  ],
+  "nfc_verifications": null,
+  "nfc_skip_reason": null,
+  "liveness_checks": [
+    {
+      "status": "Approved",
+      "method": "PASSIVE",
+      "score": 100.0,
+      "reference_image": "https://example.test/didit/reference_image.jpg",
+      "video_url": "https://example.test/didit/video_url.jpg",
+      "age_estimation": 30.33,
+      "matches": [
+        {
+          "source": "session",
+          "status": "Approved",
+          "session_id": "00000000-0000-4000-8000-000000000002",
+          "api_service": null,
+          "vendor_data": "1",
+          "user_details": {
+            "full_name": "Maria Aparecida Souza",
+            "document_type": "DL",
+            "document_number": "11122233396"
+          },
+          "is_allowlisted": false,
+          "is_blocklisted": false,
+          "session_number": 3,
+          "vendor_user_id": "34acfb38-8c65-47a5-9f3b-baca193d7ef4",
+          "match_image_url": "https://example.test/didit/match_image_url.jpg",
+          "verification_date": "2026-09-08T18:12:20Z",
+          "biometric_template_id": null,
+          "similarity_percentage": 89.4599437713623
+        },
+        {
+          "source": "session",
+          "status": "Approved",
+          "session_id": "00000000-0000-4000-8000-000000000001",
+          "api_service": null,
+          "vendor_data": "1",
+          "user_details": {
+            "full_name": "Maria Aparecida Souza",
+            "document_type": "ID",
+            "document_number": "11122233396"
+          },
+          "is_allowlisted": false,
+          "is_blocklisted": false,
+          "session_number": 2,
+          "vendor_user_id": "9954d867-632b-42d7-9d84-e237b950b22a",
+          "match_image_url": "https://example.test/didit/match_image_url.jpg",
+          "verification_date": "2026-09-08T18:04:39Z",
+          "biometric_template_id": null,
+          "similarity_percentage": 78.75661849975586
+        }
+      ],
+      "warnings": [
+        {
+          "feature": "LIVENESS",
+          "risk": "DUPLICATED_FACE",
+          "additional_data": {
+            "api_service": null,
+            "duplicated_session_id": "00000000-0000-4000-8000-000000000002",
+            "duplicated_session_number": 3
+          },
+          "log_type": "information",
+          "short_description": "Duplicated face from other approved session",
+          "long_description": "The system identified a duplicated face from another approved session, requiring further investigation.",
+          "node_id": "feature_liveness"
+        }
+      ],
+      "face_quality": 100.0,
+      "face_luminance": 49.56,
+      "node_id": "feature_liveness"
+    }
+  ],
+  "face_matches": [
+    {
+      "status": "In Review",
+      "score": null,
+      "source_image_session_id": null,
+      "source_image": "https://example.test/didit/source_image.jpg",
+      "target_image": "https://example.test/didit/target_image.jpg",
+      "warnings": [
+        {
+          "feature": "FACEMATCH",
+          "risk": "FACE_MATCH_NOT_COMPUTED",
+          "additional_data": null,
+          "log_type": "warning",
+          "short_description": "Face match could not be computed",
+          "long_description": "A face match score could not be computed because a face could not be detected in the reference image - the ID document portrait, or the reference photo supplied when the session was created. The reference image cannot be recaptured from the user, so the result follows the configured action for this case (manual review by default) rather than being treated as a low-similarity mismatch.",
+          "node_id": "feature_face_match"
+        }
+      ],
+      "node_id": "feature_face_match",
+      "face_coverage": {
+        "eyes": {
+          "covered": null,
+          "calibrated": false,
+          "visibility_score": 100.0
+        },
+        "mouth": {
+          "covered": null,
+          "calibrated": false,
+          "visibility_score": 100.0
+        },
+        "face": {
+          "covered": null,
+          "calibrated": false,
+          "visibility_score": 100.0
+        }
+      }
+    }
+  ],
+  "poa_verifications": null,
+  "phone_verifications": null,
+  "email_verifications": null,
+  "aml_screenings": null,
+  "ip_analyses": [
+    {
+      "status": "Approved",
+      "node_id": "feature_ip_analysis",
+      "device_brand": "Generic_Android",
+      "device_model": "K",
+      "browser_family": "Chrome Mobile",
+      "browser_version": "152.0.0",
+      "os_family": "Android",
+      "os_version": "10",
+      "platform": "Linux armv81",
+      "device_fingerprint": "fingerprint-ficticio",
+      "user_agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Mobile Safari/537.36",
+      "accept_language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
+      "session_identifier": null,
+      "session_age_ms": null,
+      "coords_accuracy": null,
+      "raw_device_data": {
+        "screen": {
+          "width": 444,
+          "height": 985,
+          "availWidth": 444,
+          "colorDepth": 24,
+          "pixelDepth": 24,
+          "availHeight": 985
+        },
+        "uaData": {
+          "model": "moto g56 5G",
+          "brands": [
+            {
+              "brand": "Chromium",
+              "version": "152"
+            },
+            {
+              "brand": "Not?A_Brand",
+              "version": "24"
+            },
+            {
+              "brand": "Google Chrome",
+              "version": "152"
+            }
+          ],
+          "mobile": true,
+          "platform": "Android",
+          "architecture": "",
+          "uaFullVersion": "152.0.7977.76",
+          "platformVersion": "16.0.0"
+        },
+        "battery": {
+          "level": 1,
+          "charging": false,
+          "chargingTime": null,
+          "dischargingTime": 70258
+        },
+        "language": "pt-BR",
+        "platform": "Linux armv81",
+        "integrity": {
+          "capture": {
+            "externalDevice": false,
+            "matchedPatterns": [],
+            "videoInputCount": 2,
+            "virtualCameraSuspected": false
+          },
+          "platform": "web",
+          "security": {},
+          "environment": {
+            "automationDetected": false,
+            "screenCaptureActive": null
+          },
+          "collected_at": "2026-09-08T18:19:15.416Z",
+          "schema_version": 1
+        },
+        "languages": [
+          "pt-BR",
+          "pt",
+          "en-US",
+          "en"
+        ],
+        "userAgent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Mobile Safari/537.36",
+        "deviceMemory": 8,
+        "touchSupport": true,
+        "maxTouchPoints": 5,
+        "devicePixelRatio": 2.4375,
+        "hardwareConcurrency": 8
+      },
+      "ip_country": "Brazil",
+      "ip_country_code": "BR",
+      "ip_state": "Parana",
+      "ip_city": "Marechal Cândido Rondon",
+      "latitude": 0,
+      "longitude": 0,
+      "ip_address": "203.0.113.10",
+      "isp": "Opcao Telecom",
+      "organization": "Opcao Telecom",
+      "is_vpn_or_tor": false,
+      "is_data_center": false,
+      "time_zone": "America/Sao_Paulo",
+      "time_zone_offset": "-0300",
+      "ip": {
+        "location": {
+          "latitude": 0,
+          "longitude": 0
+        },
+        "distance_from_id_document": null,
+        "distance_from_poa_document": null
+      },
+      "id_document": {
+        "location": null,
+        "distance_from_ip": null,
+        "distance_from_poa_document": null
+      },
+      "poa_document": {
+        "location": null,
+        "distance_from_ip": null,
+        "distance_from_id_document": null
+      },
+      "warnings": [
+        {
+          "feature": "LOCATION",
+          "risk": "DUPLICATED_DEVICE_FINGERPRINT",
+          "additional_data": {
+            "api_service": null,
+            "match_source": "persistent_id",
+            "duplicated_session_id": "00000000-0000-4000-8000-000000000002",
+            "duplicated_session_number": 3
+          },
+          "log_type": "information",
+          "short_description": "Duplicated device fingerprint from another session",
+          "long_description": "The same device fingerprint was detected in another session with a different vendor_data, which may indicate multiple identities verified from the same device.",
+          "node_id": "feature_ip_analysis"
+        },
+        {
+          "feature": "LOCATION",
+          "risk": "DUPLICATED_IP_ADDRESS",
+          "additional_data": {
+            "api_service": null,
+            "duplicated_session_id": "00000000-0000-4000-8000-000000000002",
+            "duplicated_session_number": 3
+          },
+          "log_type": "information",
+          "short_description": "Duplicated IP address from another session",
+          "long_description": "The same IP address was used in another session with a different vendor_data, which may indicate multiple users sharing the same network or a potential fraud pattern.",
+          "node_id": "feature_ip_analysis"
+        }
+      ],
+      "matches": [
+        {
+          "source": "session",
+          "status": "Approved",
+          "confidence": 0.0,
+          "match_mode": "co_occurrence",
+          "match_type": "ip_address",
+          "session_id": "00000000-0000-4000-8000-000000000002",
+          "api_service": null,
+          "device_info": {
+            "platform": "Linux armv81",
+            "os_family": "Android",
+            "device_brand": "Generic_Android",
+            "device_model": "K",
+            "browser_family": "Chrome Mobile",
+            "device_fingerprint": "fingerprint-ficticio"
+          },
+          "vendor_data": "1",
+          "match_source": "ip_address",
+          "location_info": {
+            "ip_city": "Marechal Cândido Rondon",
+            "ip_state": "Parana",
+            "ip_address": "203.0.113.10",
+            "ip_country": "Brazil",
+            "is_vpn_or_tor": false,
+            "is_data_center": false,
+            "ip_country_code": "BR"
+          },
+          "matched_value": "203.0.113.10",
+          "is_blocklisted": false,
+          "session_number": 3,
+          "verification_date": "2026-09-08T18:12:20Z"
+        },
+        {
+          "source": "session",
+          "status": "Approved",
+          "confidence": 0.0,
+          "match_mode": "co_occurrence",
+          "match_type": "ip_address",
+          "session_id": "00000000-0000-4000-8000-000000000001",
+          "api_service": null,
+          "device_info": {
+            "platform": "Linux armv81",
+            "os_family": "Android",
+            "device_brand": "Generic_Android",
+            "device_model": "K",
+            "browser_family": "Chrome Mobile",
+            "device_fingerprint": "fingerprint-ficticio"
+          },
+          "vendor_data": "1",
+          "match_source": "ip_address",
+          "location_info": {
+            "ip_city": "Marechal Cândido Rondon",
+            "ip_state": "Parana",
+            "ip_address": "203.0.113.10",
+            "ip_country": "Brazil",
+            "is_vpn_or_tor": false,
+            "is_data_center": false,
+            "ip_country_code": "BR"
+          },
+          "matched_value": "203.0.113.10",
+          "is_blocklisted": false,
+          "session_number": 2,
+          "verification_date": "2026-09-08T18:04:39Z"
+        },
+        {
+          "source": "session",
+          "status": "Approved",
+          "confidence": 1.0,
+          "match_mode": "deterministic",
+          "match_type": "device_fingerprint",
+          "session_id": "00000000-0000-4000-8000-000000000002",
+          "api_service": null,
+          "device_info": {
+            "platform": "Linux armv81",
+            "os_family": "Android",
+            "device_brand": "Generic_Android",
+            "device_model": "K",
+            "browser_family": "Chrome Mobile",
+            "device_fingerprint": "fingerprint-ficticio"
+          },
+          "vendor_data": "1",
+          "match_source": "persistent_id",
+          "location_info": {
+            "ip_city": "Marechal Cândido Rondon",
+            "ip_state": "Parana",
+            "ip_address": "203.0.113.10",
+            "ip_country": "Brazil",
+            "is_vpn_or_tor": false,
+            "is_data_center": false,
+            "ip_country_code": "BR"
+          },
+          "matched_value": "valor-ficticio",
+          "is_blocklisted": false,
+          "session_number": 3,
+          "verification_date": "2026-09-08T18:12:20Z"
+        },
+        {
+          "source": "session",
+          "status": "Approved",
+          "confidence": 1.0,
+          "match_mode": "deterministic",
+          "match_type": "device_fingerprint",
+          "session_id": "00000000-0000-4000-8000-000000000001",
+          "api_service": null,
+          "device_info": {
+            "platform": "Linux armv81",
+            "os_family": "Android",
+            "device_brand": "Generic_Android",
+            "device_model": "K",
+            "browser_family": "Chrome Mobile",
+            "device_fingerprint": "fingerprint-ficticio"
+          },
+          "vendor_data": "1",
+          "match_source": "persistent_id",
+          "location_info": {
+            "ip_city": "Marechal Cândido Rondon",
+            "ip_state": "Parana",
+            "ip_address": "203.0.113.10",
+            "ip_country": "Brazil",
+            "is_vpn_or_tor": false,
+            "is_data_center": false,
+            "ip_country_code": "BR"
+          },
+          "matched_value": "valor-ficticio",
+          "is_blocklisted": false,
+          "session_number": 2,
+          "verification_date": "2026-09-08T18:04:39Z"
+        }
+      ]
+    }
+  ],
+  "database_validations": null,
+  "database_validation_not_performed_reason": {
+    "code": "feature_not_unlocked",
+    "issuing_state": "BRA",
+    "configured_services": [
+      "Brazil - CNH QR code + face match (Datavalid)",
+      "Brazil - CPF status check",
+      "Brazil - CPF + face match (Datavalid)",
+      "Brazil Residential",
+      "Brazil Tax Registration (CPF/CNPJ)",
+      "BRA - Unico IDCloud (CPF + selfie)"
+    ],
+    "message": "Database validation did not run: it is locked until the organization's first paid top-up. No database was queried and nothing was billed for it."
+  },
+  "questionnaire_responses": null,
+  "document_ai_documents": null,
+  "reviews": [],
+  "contact_details": null,
+  "expected_details": {
+    "last_name": "Aparecida Souza",
+    "first_name": "Maria",
+    "id_country": "BRA",
+    "date_of_birth": "1990-01-15",
+    "identification_number": "11122233396"
+  },
+  "environment": "live",
+  "sandbox_scenario": null,
+  "created_at": "2026-09-08T18:18:44.664153Z",
+  "expires_at": "2026-09-15T18:18:44.656951Z"
+}