DiditDecisionService.php 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. <?php
  2. namespace App\Services\Didit;
  3. use App\Enums\IdentityVerificationStatusEnum;
  4. use App\Enums\UserTypeEnum;
  5. use App\Models\Client;
  6. use App\Models\IdentityVerification;
  7. use App\Models\Provider;
  8. use App\Models\User;
  9. use App\Services\ProviderService;
  10. use Illuminate\Database\Eloquent\Model;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Support\Facades\Log;
  13. class DiditDecisionService
  14. {
  15. public function __construct(
  16. private readonly DiditDecisionExtractor $extractor,
  17. private readonly ProviderService $providerService,
  18. ) {}
  19. public function handleWebhook(array $payload): ?IdentityVerification
  20. {
  21. $sessionId = data_get($payload, 'session_id');
  22. $status = (string) data_get($payload, 'status');
  23. if (! is_string($sessionId) || $sessionId === '') {
  24. Log::channel('didit')->warning('Webhook sem session_id; ignorado');
  25. return null;
  26. }
  27. $user = $this->resolveUser($payload);
  28. if (! $user) {
  29. Log::channel('didit')->warning('Webhook sem usuario correspondente', [
  30. 'session_id' => $sessionId,
  31. 'vendor_data' => data_get($payload, 'vendor_data'),
  32. ]);
  33. return null;
  34. }
  35. $decision = (array) (data_get($payload, 'decision') ?? []);
  36. $verification = $this->persist($user, $sessionId, $status, $payload, $decision);
  37. if (! in_array($status, IdentityVerificationStatusEnum::diditTerminalStatuses(), true)) {
  38. return $verification;
  39. }
  40. $this->applyDecision($user, $verification, $status, $decision);
  41. return $verification->refresh();
  42. }
  43. //
  44. private function persist(
  45. User $user,
  46. string $sessionId,
  47. string $status,
  48. array $payload,
  49. array $decision,
  50. ): IdentityVerification {
  51. $verification = IdentityVerification::firstOrNew(['session_id' => $sessionId]);
  52. $attributes = [
  53. 'user_id' => $user->id,
  54. 'didit_status' => $status,
  55. 'workflow_id' => data_get($payload, 'workflow_id') ?? $verification->workflow_id,
  56. ];
  57. if ($decision !== []) {
  58. $attributes = [
  59. ...$attributes,
  60. ...$this->extractor->extract($decision),
  61. 'decision' => $this->sanitize($decision),
  62. ];
  63. }
  64. if (! $verification->exists) {
  65. $attributes['attempt'] = (int) $user->identityVerifications()->count() + 1;
  66. $attributes['started_at'] = now();
  67. }
  68. if (in_array($status, IdentityVerificationStatusEnum::diditTerminalStatuses(), true)) {
  69. $attributes['completed_at'] = now();
  70. }
  71. $verification->fill($attributes)->save();
  72. return $verification;
  73. }
  74. private function applyDecision(
  75. User $user,
  76. IdentityVerification $verification,
  77. string $status,
  78. array $decision,
  79. ): void {
  80. $profile = $this->profileFor($user);
  81. if (! $profile) {
  82. return;
  83. }
  84. if ($status === 'Approved' && $this->passesAllGates($verification, $decision)) {
  85. $this->approve($user, $profile, $verification);
  86. return;
  87. }
  88. if ($status === 'Declined') {
  89. $this->decline($profile);
  90. return;
  91. }
  92. // Approved com pendencia, In Review ou Abandoned: decisao humana.
  93. $this->sendToReview($profile);
  94. }
  95. private function passesAllGates(IdentityVerification $verification, array $decision): bool
  96. {
  97. $required = ['id_verifications', 'liveness_checks', 'face_matches'];
  98. foreach ($required as $key) {
  99. $items = $this->extractor->items($decision, $key);
  100. if ($items === []) {
  101. $this->logGate($verification, "feature ausente: {$key}");
  102. return false;
  103. }
  104. foreach ($items as $item) {
  105. if (data_get($item, 'status') !== 'Approved') {
  106. $this->logGate($verification, "feature reprovada: {$key}");
  107. return false;
  108. }
  109. }
  110. }
  111. $minLiveness = (float) config('services.didit.liveness_min_score');
  112. $minFaceMatch = (float) config('services.didit.face_match_min_score');
  113. if ($verification->liveness_score === null || $verification->liveness_score < $minLiveness) {
  114. $this->logGate($verification, 'liveness abaixo do minimo');
  115. return false;
  116. }
  117. if ($verification->face_match_score === null || $verification->face_match_score < $minFaceMatch) {
  118. $this->logGate($verification, 'face match abaixo do minimo');
  119. return false;
  120. }
  121. if ($this->extractor->actionableWarnings($decision) !== []) {
  122. $this->logGate($verification, 'warnings acionaveis presentes');
  123. return false;
  124. }
  125. return true;
  126. }
  127. private function approve(User $user, Model $profile, IdentityVerification $verification): void
  128. {
  129. DB::transaction(function () use ($profile, $verification) {
  130. $profile->forceFill([
  131. 'identity_verification_status' => IdentityVerificationStatusEnum::APPROVED->value,
  132. 'identity_verified_at' => now(),
  133. ]);
  134. if ($profile instanceof Provider) {
  135. $profile->document_verified = true;
  136. }
  137. if ($profile->profile_media_id) {
  138. $profile->selfie_verified = true;
  139. }
  140. $profile->save();
  141. $verification->forceFill(['completed_at' => now()])->save();
  142. });
  143. if ($profile instanceof Provider) {
  144. $this->providerService->approve($profile->id);
  145. }
  146. Log::channel('didit')->info('Verificacao aprovada automaticamente', [
  147. 'user_id' => $user->id,
  148. 'session_id' => $verification->session_id,
  149. ]);
  150. }
  151. private function decline(Model $profile): void
  152. {
  153. $attempts = (int) $profile->identity_verification_attempts + 1;
  154. $max = (int) config('services.didit.max_attempts', 3);
  155. $profile->forceFill([
  156. 'identity_verification_attempts' => $attempts,
  157. 'identity_verification_status' => $attempts >= $max
  158. ? IdentityVerificationStatusEnum::IN_REVIEW->value
  159. : IdentityVerificationStatusEnum::DECLINED->value,
  160. ])->save();
  161. }
  162. private function sendToReview(Model $profile): void
  163. {
  164. $profile->forceFill([
  165. 'identity_verification_status' => IdentityVerificationStatusEnum::IN_REVIEW->value,
  166. ])->save();
  167. }
  168. private function resolveUser(array $payload): ?User
  169. {
  170. $vendorData = data_get($payload, 'vendor_data');
  171. if (is_numeric($vendorData)) {
  172. return User::find((int) $vendorData);
  173. }
  174. $sessionId = data_get($payload, 'session_id');
  175. return IdentityVerification::where('session_id', $sessionId)->first()?->user;
  176. }
  177. private function profileFor(User $user): Provider|Client|null
  178. {
  179. return $user->type === UserTypeEnum::PROVIDER
  180. ? $user->provider
  181. : $user->client;
  182. }
  183. private function sanitize(array $decision): array
  184. {
  185. array_walk_recursive($decision, static function (&$value) {
  186. if (is_string($value) && str_starts_with($value, 'http')) {
  187. $value = explode('?', $value)[0];
  188. }
  189. });
  190. return $decision;
  191. }
  192. private function logGate(IdentityVerification $verification, string $reason): void
  193. {
  194. Log::channel('didit')->info('Aprovacao automatica barrada', [
  195. 'session_id' => $verification->session_id,
  196. 'motivo' => $reason,
  197. ]);
  198. }
  199. }