# Motor Antifraude

<https://unifokal.com/docs/modulos/fraud-ai>

## Motor Antifraude

O módulo `fraud_ai` não olha o documento nem o rosto: ele olha a **sessão**. Rede e IP, aparelho, velocidade de tentativas, o e-mail informado, a sua lista de bloqueio e o reuso na **sua própria base** entram num motor de regras ponderadas que devolve um risco de 0 a 100 e o motivo de cada ponto. Não há modelo treinado no caminho: são regras determinísticas de peso fixo, e o `model_version` que chega no seu webhook diz isso na cara (`fraud-rules-1`, `fraud-rules-2`). Ele **não pede passo novo ao titular**, mas **faz consulta a fornecedor externo pago**: a reputação do IP, do e-mail e do telefone vem de uma consulta que nós fazemos por você, com cache e com teto de chamadas por verificação. O resto dos sinais já existe na verificação e no seu histórico. Quando esse fornecedor não está configurado, o motor continua rodando com os sinais próprios e os que dependem dele saem como não medidos, nunca como se fossem limpos.

! **Este é o único módulo cujo bloco não se chama `data`.** No `check_details` ele vem em `fraud_assessment`. Um parser que procura `data` em todo item lê `undefined` exatamente neste, e é o erro de integração mais caro da página, porque ele passa despercebido até o dia em que você for auditar por que o score não bate.

**Atenção à direção da escala, que é invertida em relação ao resto do payload.** `fraud_score` é **risco**: 0 é ótimo e 100 é péssimo. O `score` do próprio check, ao lado dele, segue a convenção do resto da API (alto é bom). Em produção os dois são exatamente complementares, `score = 100 menos fraud_score`, então ver `"score": 92` junto de `"fraud_score": 8` não é contradição, é a mesma medida em dois sentidos. Ramifique por `level` ou por `decision`, que não têm essa ambiguidade.

`level` tem três valores, `low`, `medium` e `high`, e é ele que determina o desfecho do check: `low` aprova (`passed: true`), `medium` fica indeterminado (`passed: null`, `outcome: "pending"`) e `high` reprova o **módulo** (`passed: false`). O campo `decision` é a mesma coisa na língua da decisão: `approve`, `review` e `decline`. **Reprovar o módulo não é reprovar a verificação**: o `fraud_ai` é um **portão suave**, então `decision: "decline"` aqui dentro não recusa nada sozinho, entra ponderado com os outros módulos. Quem decide a verificação é o `status` no topo do webhook, nunca este campo.

```
// fraud_ai no check_details: repare no "fraud_assessment" no lugar do "data"
{ "module": "fraud_ai", "passed": true, "outcome": "approved", "score": 92,
  "fraud_assessment": {
    "fraud_score": 8,           // RISCO 0..100 (baixo = bom). É o complemento do "score" acima.
    "level": "low",             // low | medium | high
    "decision": "approve",      // approve | review | decline (o módulo, não a verificação)
    "signals": {
      // TODA categoria abaixo pode sair null, e null significa NÃO MEDIDA: o vocabulário de
      // cada uma tem três valores, nunca dois. Um flow sem telefone não é um telefone limpo.
      "device": "trusted",      // trusted | flagged | null (aparelho reusado ou na sua blocklist)
      "device_reuse_count": 0,  // a CONTAGEM crua que o motor usou (null = sem vetor de features)
      "velocity": "normal",     // normal | high | null (tentativas na janela)
      "ip_risk": "normal",      // normal | elevated | null
      "ip_reuse_count": 1,
      "geo": "ok",              // ok | mismatch | null (inclui viagem impossível)
      "email": "ok",            // ok | disposable | null
      "phone": null,            // ok | flagged | null
      "company": null,          // ok | flagged | null (domínio declarado pela empresa)
      "identity_coherence": null, // ok | flagged | null (nome, nascimento, filiação, óbito)
      "device_intel": { "automation": null, "tampered": null, "incoherent": null },
      "recurrence": { "passages": 0, "prior_declines": 0, "prior_approvals": 1 },
      // Com quanto do vetor esta decisão foi tomada. "sufficient": false explica um
      // "decision": "review" com "fraud_score" baixo.
      "coverage": { "measured": 41, "total": 55, "pct": 75, "sufficient": true },
      "reasons": [],
      "reasons_text": [],
      // Explicabilidade POR REGRA: a família e o peso que cada razão contribuiu, e a soma já
      // cortada no teto por família (a soma delas é o "fraud_score"). Enquanto o motor v1 decide,
      // "applied_rules" e "families" saem null: o v1 é soma plana e não conhece família.
      "applied_rules": null,    // [{ code, family, weight, texto }] | null
      "families": null,         // { email: 10, rede: 15, ... } | null
      "engine_version": "fraud-rules-1" } } }

// fraud_ai com sinal: o mesmo aparelho em várias identidades, e o e-mail descartável
{ "module": "fraud_ai", "passed": null, "outcome": "pending", "score": 55,
  "fraud_assessment": {
    "fraud_score": 45, "level": "medium", "decision": "review",
    "signals": {
      "device": "flagged", "device_reuse_count": 4,
      "velocity": "high", "ip_risk": "elevated", "ip_reuse_count": 7,
      "geo": "ok", "email": "disposable",
      "phone": "flagged", "company": null, "identity_coherence": null,
      "device_intel": { "automation": null, "tampered": null, "incoherent": null },
      "recurrence": { "passages": 3, "prior_declines": 1, "prior_approvals": 0 },
      "coverage": { "measured": 26, "total": 55, "pct": 47, "sufficient": true },
      // códigos ESTÁVEIS de regra: ramifique por eles, nunca pelo texto do painel
      "reasons": ["device_reuse", "velocity", "disposable_email", "phone_voip"],
      // os MESMOS códigos com texto pronto para tela. O código é o contrato; o texto, não.
      "reasons_text": [
        { "code": "device_reuse", "texto": "O mesmo aparelho foi visto com várias identidades diferentes" },
        { "code": "phone_voip", "texto": "O telefone é de voz sobre IP, não de operadora móvel" } ],
      "applied_rules": null, "families": null, "engine_version": "fraud-rules-1" } } }

// cota do fornecedor estourada: o motor mediu pouco e NÃO afirma que passou
{ "module": "fraud_ai", "passed": null, "outcome": "approved", "score": 100,
  "fraud_assessment": {
    "fraud_score": 0, "level": "low", "decision": "review",
    "signals": {
      "coverage": { "measured": 6, "total": 55, "pct": 11, "sufficient": false },
      "reasons": [] } } }
```

**Há uma diferença entre "olhamos e está limpo" e "não conseguimos olhar", e o bloco diz qual dos dois foi.** Cada categoria (`device`, `velocity`, `geo`, `email`, `ip_risk`, `phone`, `company`, `identity_coherence`, `device_intel`) sai `null` quando ninguém a mediu naquela verificação, e nunca `"ok"` por omissão: um fluxo sem telefone não é um telefone verificado. O campo `coverage` fecha a conta, dizendo quantos dos 55 sinais foram medidos. Quando `"sufficient"` vem `false`, o motor mediu pouco demais para afirmar que está limpo: nesse caso `passed` sai `null` e `decision` sai `review` mesmo com `fraud_score` baixo, e é isso que o terceiro exemplo acima mostra. Isso não reprova ninguém e não muda a decisão da verificação: é o módulo se recusando a assinar embaixo de um vetor que ele não conseguiu levantar.

`reasons_text` traz as mesmas razões com texto pronto para tela. **Ramifique sempre por `reasons`, nunca pelo texto**: o código é o contrato e é estável, o texto pode mudar de redação.

`applied_rules` abre o `fraud_score` por regra: cada razão com a família a que ela pertence e o peso que ela contribuiu, e `families` traz a soma de cada família já cortada no teto dela (a soma das famílias é o `fraud_score`). `engine_version` diz qual motor produziu aquelas razões, para você conseguir comparar duas verificações separadas no tempo. **Os dois primeiros saem `null` enquanto o motor de soma plana estiver decidindo**, porque ele não conhece família nem peso por regra. As chaves já saem hoje para que a troca de motor não mude a forma da resposta.

O bloco `signals` traz duas coisas de propósito: o **estado por categoria**, que é legível e curto, e a **contagem crua** que o motor usou (`device_reuse_count`, `ip_reuse_count`). Sem a contagem você leria `"device": "flagged"` sem saber se foram dois ou quarenta cadastros no mesmo aparelho, que é a diferença entre um celular de família e uma fazenda de contas. `null` na contagem significa **não medido** naquela verificação, nunca zero. Já `reasons` é a lista de **códigos de regra** que dispararam, um vocabulário estável (`device_reuse`, `velocity`, `impossible_travel`, `blocklist_hit`, `disposable_email` e outros) do qual as categorias acima são derivadas. Ele pode ganhar códigos novos: trate como lista aberta.

**O que não está aqui, e é deliberado.** Detecção de mídia sintética não vive neste módulo nem em nenhum outro hoje: não existe modelo de deepfake com licença comercial de ponta a ponta, e não publicamos campo sem medição por trás. E nenhum valor de identificador viaja no bloco: nem IP, nem e-mail, nem o _fingerprint_ do aparelho, nem o hash de nenhum deles. Sai a **categoria** e sai a **contagem**, que é o que sustenta a sua decisão sem transformar o webhook num oráculo sobre terceiros.

**Em sandbox este módulo é o menos realista da API, e é melhor você saber por quê.** Os sinais de risco vêm do **seu histórico real** (aparelhos, IPs e tentativas da sua organização), e no ambiente de testes esse histórico não existe. Então o bloco `fraud_assessment` sai sempre no caminho limpo, com `level: "low"`, `fraud_score` baixo e `reasons: []`, enquanto `passed`, `outcome` e `score` continuam seguindo o **sufixo do documento**, como no resto do sandbox. Ou seja: lá, e só lá, dá para ver `decision: "decline"` ao lado de `level: "low"`, e a relação `score = 100 menos fraud_score` não vale. Não programe a sua conciliação contra o par que o sandbox mostra; programe contra os campos, que são os mesmos nos dois ambientes.
