AccueilÉcritsConstruire un lakehouse de paiement seul : ce que ça oblige à trancher

Construire un lakehouse de paiement seul : ce que ça oblige à trancher

Sans équipe pour absorber les approximations, chaque raccourci se paie la semaine suivante. Les décisions qui ont tenu sur NOVAPAY, et pourquoi.

Chafiq Madkour16 avril 202611 min de lectureArchitecture

NOVAPAY est une plateforme de paiement et de lutte contre la fraude que j'ai construite de bout en bout, sur Databricks et GCP : ingestion incrémentale d'environ 70 000 transactions par jour, contrôles qualité avec quarantaine réconciliable, historisation du référentiel client, scoring de risque à seuils paramétrables, restitution BigQuery. Ce texte porte sur les décisions d'architecture, et sur celles que je referais différemment.

Pourquoi construire un projet complet à côté des missions

Dix ans en banque et en assurance, c'est dix ans de travail derrière une clause de confidentialité. On ne montre pas une architecture client, on ne publie pas un extrait de code, on ne détaille pas une volumétrie. Reste une difficulté concrète : comment démontrer une manière de travailler qu'on n'a pas le droit d'exposer.

Un projet personnel complet répond à ça, à condition d'être complet justement. Un notebook de démonstration ne prouve rien : ce qui distingue une plateforme d'un prototype, c'est ce qui se passe quand une donnée est mauvaise, quand un run échoue à mi-parcours, quand un fournisseur change son format sans prévenir. Ces situations n'apparaissent que si on tient le projet dans la durée.

Le bronze ne se corrige pas

La couche bronze reçoit les transactions telles qu'elles arrivent, avec deux colonnes ajoutées : le fichier d'origine et l'horodatage d'ingestion. Aucune transformation, aucune correction, aucune opinion.

La tentation de corriger dès le bronze est forte, parce que l'erreur est visible et le correctif tient en une ligne. Une devise mal orthographiée, un montant en centimes au lieu d'euros : on le voit, on le répare, on passe à autre chose. C'est exactement ce qui rend une plateforme non auditable. Six mois plus tard, personne ne distingue ce qui est arrivé de ce qui a été réparé, et la question « pourquoi ce total a changé » n'a plus de réponse.

(spark.readStream.format("cloudFiles")
   .option("cloudFiles.format", "json")
   .option("cloudFiles.schemaLocation", f"{chemin}/_schema")
   .option("cloudFiles.schemaEvolutionMode", "addNewColumns")
   .load(source)
   .withColumn("_fichier",  input_file_name())
   .withColumn("_ingere_le", current_timestamp())
   .writeStream
   .option("checkpointLocation", f"{chemin}/_checkpoint")
   .trigger(availableNow=True)
   .toTable("bronze.transactions"))

Deux options portent tout le sens ici. schemaEvolutionMode en addNewColumns : un fournisseur de paiement ajoute un champ sans prévenir, et je préfère que la chaîne l'accueille plutôt qu'elle s'arrête — ou pire, qu'elle l'ignore silencieusement. Et le checkpointLocation, qui rend l'ingestion reprenable : une panne à deux heures du matin ne coûte pas une journée de rattrapage.

Cinq règles, pas trente

Le moteur de qualité applique cinq règles, aux comportements volontairement distincts. La tentation inverse — écrire trente règles pour couvrir tous les cas imaginables — produit un système que personne ne peut raisonner et dont les rejets ne sont plus interprétables.

La propriété centrale n'est pas le nombre de règles : c'est qu'une règle route, elle ne filtre jamais. Une ligne non conforme part en quarantaine avec le code de la règle qui l'a écartée. Elle n'est jamais supprimée.

REGLES = [
    Regle("R01_DEVISE_INCONNUE",   ~col("devise").isin(*DEVISES_CONNUES)),
    Regle("R02_MONTANT_ABSENT",     col("montant").isNull()),
    Regle("R03_MONTANT_NEGATIF",    col("montant") < 0),
    Regle("R04_MARCHAND_INCONNU",   col("libelle_marchand").isNull()),
    Regle("R05_HORODATAGE_FUTUR",   col("date_transaction") > current_timestamp()),
]

marque = reduce(
    lambda df, r: df.withColumn(r.code, r.condition),
    REGLES, enrichi,
)
en_defaut = reduce(lambda a, b: a | b, [col(r.code) for r in REGLES])

conformes  = marque.filter(~en_defaut)
quarantaine = marque.filter(en_defaut).withColumn(
    "regles_violees",
    array_compact(array(*[when(col(r.code), lit(r.code)) for r in REGLES])),
)

Et le contrôle qui rend la classe entière de défauts impossible, exécuté à chaque run :

recu = bronze_du_jour.count()
traite, ecarte = conformes.count(), quarantaine.count()
assert recu == traite + ecarte, f"perte silencieuse : {recu - traite - ecarte} lignes"

Ce que je referais différemment : dès le départ, une alerte sur l'ancienneté du stock de quarantaine, pas seulement sur sa taille. Une quarantaine dont rien ne sort est une poubelle mieux nommée, et on ne s'en aperçoit qu'en regardant le taux de sortie.

La conversion de devises, piège discret

C'est la décision qui m'a le plus surpris par ses conséquences. Convertir les montants avec le taux du jour de traitement paraît naturel — et rend tous les montants irreproductibles dès le lendemain. Un rapport régénéré une semaine plus tard ne donne plus les mêmes chiffres, sans qu'aucune donnée source n'ait changé.

Un montant converti sans son taux et sa date n'est pas une donnée, c'est un instantané. Il faut historiser le taux au même titre que le référentiel.

La forme qui tient : une table de taux historisée, jointe sur la date de la transaction et non sur la date de traitement, et le taux conservé dans la ligne de sortie.

silver = (transactions.alias("t")
    .join(taux.alias("x"),
          (col("t.devise") == col("x.devise")) &
          (to_date(col("t.date_transaction")) == col("x.date_taux")),
          "left")
    .withColumn("taux_applique", col("x.taux"))          # conservé, pas seulement utilisé
    .withColumn("montant_eur",   col("t.montant") * col("x.taux")))

La jointure est volontairement externe : un taux manquant est une anomalie à router, pas une ligne à perdre.

Le référentiel client suit la même logique, en historisation SCD 2 : on doit pouvoir répondre à « que savait-on de ce client au moment de la transaction ». Sans ça, un scoring rejoué donne un résultat différent de celui qui avait déclenché l'alerte — et une alerte qu'on ne peut pas reproduire ne peut pas être défendue.

Ce que le délai de détection mesure vraiment

Le chiffre que je retiens du projet n'est pas un temps de calcul : c'est le délai entre une transaction suspecte et le moment où quelqu'un peut agir. Il est passé de vingt-six heures à moins de quinze minutes.

La partie intéressante, c'est d'où venaient ces vingt-six heures. Presque rien dans le calcul. L'essentiel tenait à la cadence : un traitement quotidien lancé la nuit, sur les données de la veille arrêtées à minuit. Une transaction de dix heures du matin attendait donc quatorze heures avant même d'entrer dans la chaîne, puis le temps du batch, puis la publication.

Le gain est venu du passage à une ingestion incrémentale déclenchée à l'arrivée des fichiers, pas d'une optimisation Spark. C'est une leçon générale : sur une chaîne de détection, le temps de calcul est rarement le poste dominant. Avant d'optimiser un job, il faut mesurer le délai de bout en bout — de l'événement à l'action possible — et regarder où sont réellement passées les heures.

Le dernier point, moins technique. Construire seul oblige à trancher là où une équipe pourrait reporter. Aucune de ces décisions n'est nouvelle, et aucune n'est propre au paiement. Ce que le projet apporte, c'est qu'elles sont visibles : le code est ouvert, l'architecture est publiable, et chaque choix peut être discuté sur pièces plutôt que sur parole.

Chafiq Madkour est Tech Lead et Senior Data Engineer. Dix ans sur des plateformes data critiques en banque, en assurance et en finance de marché — déclaratifs réglementaires, détection de fraude, moteurs de calcul de risque.