.NET
Criando uma regra simples de fraud detection em .NET
Um passo a passo de uma fraud rule real: a interface, o tipo do veredito, a query no repository por trás dela e por que tudo isso são umas 20 linhas de lógica de verdade.
Fraud detection parece que deveria ser complicado. A primeira regra de verdade que escrevi tem umas vinte linhas, e a maior parte do trabalho foi no formato em volta dela, não na lógica em si. Aqui está tudo, pedaço por pedaço.
Comece pelo contrato
Toda regra implementa uma interface. É isso que permite adicionar regras sem tocar no código que as executa:
public interface IFraudRule
{
string RuleName { get; }
Task<FraudVerdict> EvaluateAsync(Payment payment, CancellationToken ct = default);
}
Duas decisões que valem ser destacadas. RuleName existe porque, quando um pagamento é rejeitado, você precisa saber qual regra fez isso, e essa string acaba guardada junto com a flag. E EvaluateAsync devolve um veredito em vez de um bool, porque "suspeito" sozinho não serve para nada quando alguém pergunta o motivo depois.
O tipo do veredito
public record FraudVerdict(bool IsSuspicious, string? Details)
{
public static FraudVerdict Clean { get; } = new(false, null);
public static FraudVerdict Suspicious(string details) => new(true, details);
}
Clean é uma única instância em cache, já que não carrega informação nenhuma, enquanto Suspicious obriga você a passar os detalhes. Não é possível construir um veredito suspeito sem explicá-lo, e essa é justamente a ideia.
A regra em si
O padrão que eu queria pegar é card testing: alguém com uma lista de números de cartão roubados fazendo transações pequenas para descobrir quais cartões ainda estão ativos antes de gastar de verdade. Isso aparece como recusas repetidas no mesmo cartão em uma janela curta de tempo.
public class DeclineVelocityRule(IPaymentReadOnlyRepository readRepo) : IFraudRule
{
private const int MaxRecentDeclines = 3;
private static readonly TimeSpan Window = TimeSpan.FromHours(24);
public string RuleName => "DeclineVelocity";
public async Task<FraudVerdict> EvaluateAsync(Payment payment, CancellationToken ct = default)
{
var declines = await readRepo.CountRecentDeclinedAttempts(
payment.CardBin, payment.CardLast4, Window);
return declines >= MaxRecentDeclines
? FraudVerdict.Suspicious($"Card reached {declines} declined attempts within {Window.TotalHours}h.")
: FraudVerdict.Clean;
}
}
É a regra inteira. Contar as recusas recentes desse cartão, comparar com um limite e devolver um veredito com uma explicação.
As partes que não são a lógica
Três coisas ali importam mais do que a comparação em si.
A regra identifica um cartão pelo BIN e pelos últimos quatro dígitos, não pelo número completo, porque o número completo não é armazenado em lugar nenhum. Isso basta para reconhecer o mesmo cartão entre transações sem guardar dados que eu não deveria ter.
Ela depende de uma interface de repository read-only em vez de um DbContext. A regra não sabe nem se importa se a contagem vem do SQLite, do SQL Server ou de um cache, e é isso que a torna testável sem database.
O limite e a janela são constantes no topo, em vez de magic numbers enterrados na comparação. Três recusas em 24 horas é um julgamento, não um fato, e é o tipo de número que se ajusta quando você vê tráfego real, então precisa ser óbvio onde mudá-lo.
Onde ela roda
A regra é avaliada antes de qualquer payment gateway ser acionado. Um cartão que já parece estar sendo testado não custa nada para rejeitar, e não há motivo para gastar uma chamada ao gateway descobrindo o que o histórico já diz.
O que eu mudaria com dados reais
O limite é um palpite. Três recusas em 24 horas parece razoável, mas um sistema real ajustaria isso contra o tráfego de verdade, e provavelmente variaria por valor ou por quão antigo é o cliente. Um cliente novinho falhando três vezes é um sinal muito diferente de um cliente habitual falhando três vezes porque o cartão acabou de vencer.
Essa é a parte que um projeto de estudo não consegue me ensinar. O que ele ensina é o formato: um contrato, um veredito que se explica e limites em algum lugar óbvio o bastante para mudar quando você descobre que estavam errados.