Code: Motor antifraude que “protege receita” mas bloqueia compras

Este é um problema real e recorrente em e-commerce e fintech: o motor antifraude é calibrado para reduzir chargeback e perdas. No começo, melhora os números. Com o tempo, começa a reprovar ou segurar pedidos legítimos de forma concentrada em certos CEPs e padrões de compra. A operação vê “fraude cair” na média — e, sem perceber, cria dano por coorte: um subconjunto de clientes paga com mais fricção, mais recusas e mais tempo de espera.

Aqui, Code Map é um diagnóstico: mapear onde o atrito e os falsos positivos se concentram, quem paga por isso e quais mecanismos estão produzindo a concentração.

AS-IS — decisão binária, pouca evidência, nenhum freio de coorte

No AS-IS, o antifraude costuma virar um classificador com saída binária (aprova/nega) ou ternária (aprova/revisa/nega), otimizado por taxa global. O código “funciona”, mas não carrega governança: não deixa recibo contestável, não mede impacto por coorte, e trata “nega” como ação barata (quando, na prática, é quase irreversível).

# AS-IS (exemplo simplificado): decisão binária por score.
# Problemas típicos:
# - features de localização entram como proxies fortes (CEP/geo)
# - decisão final não deixa recibo auditável
# - não mede falsos positivos por coorte
# - "deny" é tratado como reversível, mas é perda real (abandono)

from dataclasses import dataclass
from typing import Dict, Any

@dataclass
class Order:
    order_id: str
    amount: float
    is_first_purchase: bool
    payment_method: str
    ip_country: str
    delivery_zip: str
    device_risk: float
    chargeback_history_score: float  # do usuário ou do cartão (ex.: 0..1)

def model_score(features: Dict[str, Any]) -> float:
    # Placeholder: modelo treinado para risco (0..1)
    # (em AS-IS, proxies como ZIP/geo podem dominar silenciosamente)
    base = 0.15
    base += 0.35 * features["device_risk"]
    base += 0.30 * features["chargeback_history_score"]
    base += 0.10 if features["is_first_purchase"] else 0.0
    base += 0.10 if features["payment_method"] in {"prepaid", "virtual_card"} else 0.0
    base += 0.20 if features["delivery_zip"].startswith(("0", "1")) else 0.0  # proxy bruto (exemplo)
    return min(1.0, base)

def fraud_decision(order: Order) -> Dict[str, Any]:
    features = {
        "amount": order.amount,
        "is_first_purchase": order.is_first_purchase,
        "payment_method": order.payment_method,
        "ip_country": order.ip_country,
        "delivery_zip": order.delivery_zip,
        "device_risk": order.device_risk,
        "chargeback_history_score": order.chargeback_history_score,
    }
    score = model_score(features)

    # Regra simples: acima do limiar, nega.
    decision = "deny" if score >= 0.65 else "approve"

    # Quase nenhum recibo: apenas um score agregado.
    return {"order_id": order.order_id, "decision": decision, "risk_score": score}

Depois de algum tempo, esse desenho costuma produzir exatamente o que o Code Map detecta: melhora agregada com dano concentrado. O time sente isso como “alguns bairros dão problema”, “clientes novos reclamam mais”, “cai conversão em certas regiões”. Mas sem instrumentação por coorte e sem recibos, a operação não consegue provar se está reduzindo fraude ou apenas empurrando custo para um segmento específico.

TO-BE — decisão governável: medir coortes, degradar com baixo dano, e registrar recibos

No TO-BE, o Code Map recomenda transformar antifraude em um sistema de decisão com quatro travas: observabilidade por coorte, saídas de baixo dano quando a confiança cai, limites sobre proxies e recibos contestáveis. O objetivo deixa de ser “negar melhor” e passa a ser “controlar risco sem concentrar dano”.

# TO-BE (exemplo simplificado): decisão com governança no runtime.
# Melhorias:
# - mede e registra coorte (para diagnóstico CM)
# - troca "deny" por degradação controlada (step-up) quando possível
# - impõe limite: proxies (ex.: ZIP) não podem ser motivo único para decisão irreversível
# - gera recibo replayável (evidence + reasons + policy_version)

from dataclasses import dataclass
from typing import Dict, Any, List, Tuple
import time
import uuid

@dataclass
class Order:
    order_id: str
    amount: float
    is_first_purchase: bool
    payment_method: str
    ip_country: str
    delivery_zip: str
    device_risk: float
    chargeback_history_score: float
    account_age_days: int
    delivery_confirmable: bool  # ex.: endereço validado / ponto de retirada disponível

POLICY_VERSION = "fraud_policy_v3.2"

def cohort_key(order: Order) -> str:
    # Exemplo: coorte operacional (não “atributo sensível”; foco em onde o atrito se concentra)
    zip_prefix = order.delivery_zip[:2] if order.delivery_zip else "??"
    return f"zip:{zip_prefix}|pm:{order.payment_method}|first:{int(order.is_first_purchase)}"

def model_score(features: Dict[str, Any]) -> float:
    # Placeholder: modelo de risco (0..1)
    base = 0.12
    base += 0.35 * features["device_risk"]
    base += 0.30 * features["chargeback_history_score"]
    base += 0.08 if features["is_first_purchase"] else 0.0
    base += 0.08 if features["payment_method"] in {"prepaid", "virtual_card"} else 0.0
    base += 0.05 if features["account_age_days"] < 7 else 0.0
    # ZIP entra, mas com peso menor e nunca como motivo único para decisão irreversível:
    base += 0.06 if features["delivery_zip"].startswith(("0", "1")) else 0.0
    return min(1.0, base)

def policy_constraints(order: Order, score: float, reasons: List[str]) -> Tuple[str, List[str]]:
    """
    Retorna (decision, reasons) aplicando limites:
    - "deny" só quando houver evidência forte além de proxies
    - preferir step-up quando há alternativa de baixo dano
    """
    has_strong_non_proxy = any(r in reasons for r in ["high_device_risk", "high_chargeback_signal"])
    proxy_only = (not has_strong_non_proxy) and any(r.startswith("zip_proxy") for r in reasons)

    # 1) Se risco alto mas sem evidência forte além de proxy, NÃO negar; pedir step-up.
    if score >= 0.70 and proxy_only:
        return "step_up", reasons + ["policy:no_irreversible_action_on_proxy_only"]

    # 2) Se risco alto e há evidência forte, negar ou revisar.
    if score >= 0.80 and has_strong_non_proxy:
        return "deny", reasons + ["policy:irreversible_allowed_with_strong_evidence"]

    # 3) Zona cinza: revisão/step-up
    if score >= 0.60:
        return ("step_up" if order.delivery_confirmable else "review"), reasons + ["policy:degrade_when_uncertain"]

    return "approve", reasons + ["policy:low_risk"]

def derive_reasons(order: Order) -> List[str]:
    reasons = []
    if order.device_risk >= 0.8:
        reasons.append("high_device_risk")
    if order.chargeback_history_score >= 0.7:
        reasons.append("high_chargeback_signal")
    if order.is_first_purchase:
        reasons.append("first_purchase")
    if order.payment_method in {"prepaid", "virtual_card"}:
        reasons.append("payment_risk_method")
    if order.account_age_days < 7:
        reasons.append("new_account")
    if order.delivery_zip.startswith(("0", "1")):
        reasons.append("zip_proxy:prefix_01")  # marcado explicitamente como proxy
    return reasons

def write_receipt(receipt: Dict[str, Any]) -> None:
    # Placeholder: log/audit store + métricas agregadas por coorte
    print("[FRAUD_RECEIPT]", receipt)

def fraud_decision(order: Order) -> Dict[str, Any]:
    request_id = f"fr_{uuid.uuid4().hex}"
    features = {
        "amount": order.amount,
        "is_first_purchase": order.is_first_purchase,
        "payment_method": order.payment_method,
        "ip_country": order.ip_country,
        "delivery_zip": order.delivery_zip,
        "device_risk": order.device_risk,
        "chargeback_history_score": order.chargeback_history_score,
        "account_age_days": order.account_age_days,
    }

    score = model_score(features)
    reasons = derive_reasons(order)
    decision, reasons = policy_constraints(order, score, reasons)

    receipt = {
        "ts": int(time.time()),
        "request_id": request_id,
        "policy_version": POLICY_VERSION,
        "order_id": order.order_id,
        "cohort": cohort_key(order),
        "risk_score": score,
        "decision": decision,
        "reasons": reasons,
        "evidence": {
            # evidência mínima para replay (sem “vazar” dado desnecessário)
            "device_risk_bucket": "high" if order.device_risk >= 0.8 else "ok",
            "chargeback_signal_bucket": "high" if order.chargeback_history_score >= 0.7 else "ok",
            "account_age_days": order.account_age_days,
            "delivery_confirmable": order.delivery_confirmable,
        },
        "next_step": (
            "3ds_or_otp" if decision == "step_up" else
            "manual_review_queue" if decision == "review" else
            "deny_with_appeal_path" if decision == "deny" else
            "fulfill"
        ),
    }
    write_receipt(receipt)

    return {
        "order_id": order.order_id,
        "decision": decision,
        "risk_score": score,
        "request_id": request_id,
        "next_step": receipt["next_step"],
    }

Antes, o sistema “comprava segurança” com uma moeda invisível: fricção concentrada em coortes específicas. Agora, o sistema passa a ter três propriedades que o diagnóstico Code Map considera essenciais:

  • Visibilidade por coorte: toda decisão carrega um rótulo operacional (cohort) e vira mensurável.
  • Saída de baixo dano: “nega” deixa de ser default; entra step-up/review quando há incerteza.
  • Limite sobre proxy: o sistema impede decisões irreversíveis quando a justificativa é “proxy-only”, forçando degradação segura.

Análise — o que Code Map com código muda de verdade

A diferença não está em “usar um modelo melhor”. Está em tornar a decisão antifraude governável: medir onde dói, evitar irreversibilidade quando a evidência é fraca, e produzir recibos contestáveis. No AS-IS, a empresa otimiza média e descobre tarde o dano concentrado. No TO-BE, a empresa cria mecanismos para enxergar e corrigir concentração cedo — antes que vire perda de confiança e receita.

Se o sistema precisasse escolher uma única prioridade imediata, a pergunta Code Map não seria “como negar mais fraude?”. Seria: em quais cohorts os falsos positivos estão se acumulando, e qual alternativa de baixo dano existe quando a confiança cai?

Veja também: