← tous les articles

.NET

Construire une règle de fraud detection simple en .NET

Le parcours d'une vraie fraud rule : l'interface, le type de verdict, la query du repository derrière, et pourquoi l'ensemble tient en une vingtaine de lignes de logique réelle.

Construire une règle de fraud detection simple en .NET

Le fraud detection a l'air de quelque chose qui devrait être compliqué. La première vraie règle que j'ai écrite fait une vingtaine de lignes, et l'essentiel du travail est parti dans la forme autour, pas dans la logique elle-même. Voilà le tout, morceau par morceau.

Commencer par le contrat

Chaque règle implémente une seule interface. C'est ce qui permet d'ajouter des règles sans toucher au code qui les exécute :

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

Deux décisions méritent d'être signalées. RuleName existe parce que, quand un paiement est rejeté, il faut savoir quelle règle l'a fait, et cette string finit stockée avec la flag. Et EvaluateAsync renvoie un verdict plutôt qu'un bool, parce que « suspect » tout seul ne sert à rien quand quelqu'un demande pourquoi plus tard.

Le type de verdict

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 est une instance unique mise en cache, puisqu'elle ne porte aucune information, alors que Suspicious vous oblige à passer des détails. Impossible de construire un verdict suspect sans l'expliquer, et c'est bien l'objectif.

La règle elle-même

Le schéma que je voulais attraper, c'est le card testing : quelqu'un avec une liste de numéros de carte volés qui passe de petites transactions pour découvrir quelles cartes sont encore actives avant de dépenser pour de vrai. Ça ressemble à des refus répétés sur la même carte dans un intervalle court.

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

Voilà toute la règle. Compter les refus récents de cette carte, comparer à un seuil, renvoyer un verdict avec une explication.

Les parties qui ne sont pas la logique

Trois choses là-dedans comptent plus que la comparaison elle-même.

La règle identifie une carte par son BIN et ses quatre derniers chiffres, pas par le numéro complet, parce que le numéro complet n'est stocké nulle part. C'est suffisant pour reconnaître la même carte d'une transaction à l'autre sans conserver des données que je ne devrais pas avoir.

Elle dépend d'une interface de repository read-only plutôt que d'un DbContext. La règle ne sait pas et ne se soucie pas de savoir si le compte vient de SQLite, de SQL Server ou d'un cache, et c'est ce qui la rend testable sans database.

Le seuil et la fenêtre sont des constantes en haut, au lieu de magic numbers enfouis dans la comparaison. Trois refus en 24 heures, c'est un jugement, pas un fait, et c'est typiquement le genre de valeur qu'on ajuste dès qu'on voit du trafic réel, donc l'endroit où la changer doit être évident.

Où elle s'exécute

La règle est évaluée avant de contacter le moindre payment gateway. Une carte qui a déjà l'air d'être testée ne coûte rien à rejeter, et il n'y a aucune raison de dépenser un appel au gateway pour découvrir ce que l'historique dit déjà.

Ce que je changerais avec de vraies données

Le seuil est une supposition. Trois refus en 24 heures semble raisonnable, mais un vrai système l'ajusterait au trafic réel, et le ferait probablement varier selon le montant ou selon l'ancienneté du client. Un client tout nouveau qui échoue trois fois est un signal très différent d'un habitué qui échoue trois fois parce que sa carte vient d'expirer.

C'est la partie qu'un projet d'apprentissage ne peut pas vraiment m'apprendre. Ce qu'il peut apprendre, c'est la forme : un contrat, un verdict qui s'explique de lui-même, et des seuils placés à un endroit assez évident pour les changer quand on découvre qu'ils sont faux.