← tutti gli articoli

.NET

Costruire una semplice regola di fraud detection in .NET

Il percorso di una vera fraud rule: l'interfaccia, il tipo del verdetto, la query sul repository che ci sta dietro e perché tutto questo sono circa 20 righe di logica effettiva.

Costruire una semplice regola di fraud detection in .NET

Il fraud detection sembra qualcosa che dovrebbe essere complicato. La prima regola vera che ho scritto è di circa venti righe, e la maggior parte del lavoro è finita nella forma che le sta attorno, non nella logica in sé. Ecco tutto, pezzo per pezzo.

Si parte dal contratto

Ogni regola implementa una sola interfaccia. È questo che permette di aggiungere regole senza toccare il codice che le esegue:

public interface IFraudRule
{
    string RuleName { get; }
    Task<FraudVerdict> EvaluateAsync(Payment payment, CancellationToken ct = default);
}

Due scelte che vale la pena sottolineare. RuleName esiste perché, quando un pagamento viene rifiutato, devi sapere quale regola l'ha fatto, e quella string finisce salvata insieme alla flag. E EvaluateAsync restituisce un verdetto invece di un bool, perché "sospetto" da solo non serve a niente quando qualcuno chiede il perché più tardi.

Il tipo del verdetto

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 è una singola istanza in cache, dato che non porta nessuna informazione, mentre Suspicious ti obbliga a passare i dettagli. Non puoi costruire un verdetto sospetto senza spiegarlo, ed è esattamente il punto.

La regola vera e propria

Lo schema che volevo intercettare è il card testing: qualcuno con una lista di numeri di carta rubati che fa transazioni piccole per scoprire quali carte sono ancora attive prima di spendere davvero. Si presenta come rifiuti ripetuti sulla stessa carta in una finestra breve.

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;
    }
}

È tutta la regola. Conta i rifiuti recenti per questa carta, confronta con una soglia, restituisce un verdetto con una spiegazione.

Le parti che non sono la logica

Tre cose lì contano più del confronto in sé.

La regola identifica una carta tramite BIN e ultime quattro cifre, non tramite il numero completo, perché il numero completo non viene salvato da nessuna parte. Basta per riconoscere la stessa carta tra transazioni diverse senza tenere dati che non dovrei avere.

Dipende da un'interfaccia di repository read-only invece che da un DbContext. La regola non sa e non le interessa se il conteggio arriva da SQLite, da SQL Server o da una cache, ed è questo che la rende testabile senza database.

La soglia e la finestra sono costanti in cima, invece di magic numbers sepolti nel confronto. Tre rifiuti in 24 ore è una valutazione, non un fatto, ed è il tipo di numero che si tara quando vedi traffico reale, quindi deve essere ovvio dove cambiarlo.

Dove viene eseguita

La regola viene valutata prima di contattare qualsiasi payment gateway. Una carta che sembra già in fase di testing non costa niente da rifiutare, e non c'è motivo di spendere una chiamata al gateway per scoprire quello che lo storico dice già.

Cosa cambierei con dati reali

La soglia è un'ipotesi. Tre rifiuti in 24 ore sembra ragionevole, ma un sistema reale la tarerebbe sul traffico effettivo, e probabilmente la farebbe variare per importo o per quanto è consolidato il cliente. Un cliente nuovissimo che sbaglia tre volte è un segnale molto diverso da un cliente abituale che sbaglia tre volte perché la carta è appena scaduta.

Questa è la parte che un progetto di studio non può insegnarmi. Quello che può insegnare è la forma: un contratto, un verdetto che si spiega da sé e soglie in un posto abbastanza ovvio da poterle cambiare quando scopri che sono sbagliate.