asap.cool — produit indépendant

Développer un SaaS de facturation conforme en Rust

NF203, Factur-X, eIDAS : ce que j'ai dû câbler dans asap.cool pour qu'une facture ne puisse plus être modifiée après coup.

Mon rôle : Fondateur & ingénieur produit. Voir le produit en ligne

Photo du site de asap.cool

Le problème

Ton produit émet des factures pour tes utilisateurs. Au début, c’est un bouton « Télécharger le PDF » et un compteur qui s’incrémente. Puis un utilisateur corrige une adresse sur une facture déjà envoyée, un autre en supprime une par erreur, et tu réalises que ton compteur a un trou. À ce moment-là, tu n’as plus un générateur de PDF, tu as un logiciel de gestion au sens du fisc, et développer un SaaS de facturation conforme devient le vrai sujet.

L’article 286 I 3° bis du CGI pose quatre conditions à ce type de logiciel : inaltérabilité, sécurisation, conservation et archivage des données. Ce n’est pas une norme d’affichage, c’est une contrainte sur ce que ta base de données accepte de faire une fois qu’une facture existe.

Deux certifications se confondent souvent. NF525 vise les systèmes de caisse, l’encaissement. NF203 vise les logiciels de comptabilité et de gestion commerciale, donc un module de facturation. Par-dessus, la facturation électronique se généralise à partir de septembre 2026, et Factur-X est le format que j’ai choisi. Un éditeur qui commence par le PDF et remonte ensuite vers ces contraintes refait son module. J’ai préféré partir de la contrainte.

L’approche

asap.cool est mon outil de pilotage pour freelances : prospection, devis, factures, signature, trésorerie. La facturation n’y est pas tout le produit, c’est un module au milieu du reste, ce qui rend la question du périmètre encore plus concrète : tout ce qui touche à une facture doit tenir devant un contrôle, sans ralentir le reste de l’application. Le module a démarré en mai 2026, quand j’ai migré les utilisateurs de Deviso.pro, mon prédécesseur en Next.js, vers la nouvelle plateforme en Rust.

Ce que la conformité a changé dans le codeQuatre blocs, dans l'ordre où ils se sont posésFinaliserune transaction qui scelle la facture, pas un flagFactur-Xun PDF/A-3 qui embarque son XML, vérifié par des outils tiersSignerune preuve eIDAS simple qu’on peut rejouerLes agentsun copilote qui n’a que les droits de l’utilisateur

La finalisation, une transaction et pas un statut

Le piège classique, c’est de finaliser une facture en passant une colonne status de draft à issued. Deux requêtes simultanées, deux factures avec le même numéro, ou un numéro sauté. Sur asap.cool, finaliser est une seule transaction Postgres, dans un crate dédié invoice-finalize partagé par l’API et le worker.

Dans l’ordre : la ligne de invoice.number_sequence propre à l’organisation, au type de document et à l’année est verrouillée, et le numéro suivant est attribué sans trou. Le client est gelé dans un snapshot, parce que son adresse peut changer demain et que la facture, non. Le contenu est haché en SHA-256. Le hash devient un maillon ajouté à invoice.integrity_chain, la chaîne append-only de l’organisation. Puis la facture est scellée. Si une étape échoue, rien n’est écrit.

Le hash porte sur une sérialisation canonique écrite à la main, pas serde_json, dont la sortie n’est pas contractuelle entre versions : le même document doit donner le même hash dans deux ans.

Le dry-run utilise exactement le même code, suivi d’un rollback. Ce que l’utilisateur voit en prévisualisation est ce qui sera écrit. Et le compteur sait se protéger tout seul : depuis fin juin 2026, la finalisation compare la séquence au plus grand numéro déjà présent pour l’organisation, le type et l’année, et ne repart jamais en dessous.

Le cas visé, c’est un import depuis un autre outil, comme celui des utilisateurs de Deviso : un numéro déjà attribué ne peut pas être réémis, quelle que soit la valeur avec laquelle la séquence a été initialisée.

Factur-X, un PDF qui contient son propre XML

Une facture Factur-X, c’est un PDF/A-3 lisible par un humain qui embarque un XML CII lisible par une machine. Le PDF est rendu par Typst, qui exporte un PDF/A-3 valide. Ensuite, un module Rust greffe dessus les métadonnées XMP Factur-X avec lopdf, et attache le XML généré par le module cii.

Le point qui compte, c’est la vérification. Un PDF/A-3 qui a l’air bon ne suffit pas, et je ne fais pas confiance à mon propre code pour juger de sa conformité. Un workflow CI dédié fait passer chaque rendu par veraPDF, pour la conformité PDF/A, et par Mustang, pour la validité du XML CII, avec un fichier volontairement invalide qui doit être rejeté pour prouver que le contrôle mord. Le profil annoncé dans le fichier est BASIC. L’enum des niveaux modélise aussi EN16931 et EXTENDED, mais un PDF n’annonce jamais un niveau que la CI n’a pas validé.

Une signature eIDAS simple, mais une vraie preuve

Signer électroniquement une facture française n’est pas obligatoire. C’est une des voies pour garantir authenticité et intégrité, à côté de la piste d’audit fiable et de l’EDI. Sur asap.cool, les devis et documents à faire signer passent par un module signature qui implémente le niveau eIDAS simple : le signataire consent et signe, sans challenge d’identité.

Ce qui rend le niveau simple défendable, c’est la preuve. À la fin du parcours, le module assemble un « proof bundle » : chaque événement du parcours est chaîné, et réordonner, supprimer ou modifier un événement casse la chaîne. Les niveaux avancé et qualifié existent dans le type AssuranceLevel, mais ils ne sont pas implémentés et le produit ne les revendique pas.

Les agents ont les droits de l’utilisateur, pas plus

asap.cool embarque un copilote et un serveur MCP qui expose 58 outils à un modèle. Dans un module où chaque finalisation doit être défendable, la question n’est pas ce que le modèle sait faire, mais ce qu’il a le droit d’appeler. Le copilote maintient une liste blanche stricte : un nom d’outil inconnu est rejeté avant d’atteindre quoi que ce soit.

Et le serveur MCP n’a pas de clé à lui. Il transmet le bearer de l’appelant à l’API, donc l’agent hérite exactement des permissions de l’utilisateur, RLS comprise. Il ne fait rien que cet utilisateur ne pourrait faire lui-même depuis l’interface : une facture d’une autre organisation n’existe tout simplement pas pour lui, la base ne la renvoie pas. Et deux confirmations concurrentes sur la même action ne peuvent pas réussir toutes les deux.

Stack et choix techniques

Le backend est un modulith Rust sur Axum, un seul déployable, découpé en 31 crates : les core/* transverses (auth, paiements, rendu PDF, événements, jobs) et les modules/* métier (invoice, signature, copilot, calendrier, messagerie). Les requêtes passent par sqlx, et une partie des requêtes est vérifiée à la compilation. Postgres porte plus de 100 tables en FORCE ROW LEVEL SECURITY, donc l’isolation entre organisations ne dépend pas d’un WHERE org_id oublié. J’en dis plus dans choisir son architecture technique.

Les traitements longs, rendu PDF, livraison des webhooks sortants, passent par pgmq, une file de jobs dans Postgres : les tâches vivent dans la même base que les données, sous la même RLS. Côté front, une SPA React 19 pour l’application et Astro pour le site marketing, asap.cool. Rust n’est pas la stack la plus rapide pour prototyper. C’est celle où une facture finalisée sans son snapshot client ne compile pas.

Résultat

Ce sont des chiffres de dépôt, relevés le 20 septembre 2026 : ils disent la taille et la discipline de test du projet, pas son usage.

IndicateurValeur
Commits depuis le 19 mai 2026973
Lignes de code Rust179 264
Crates du workspace31
Migrations119
Tables en FORCE ROW LEVEL SECURITYplus de 100
Endpoints API (OpenAPI)208
Outils exposés au copilote (MCP)58
Tests Rust1 559
Specs end-to-end (Playwright)14
Workflows CI10

Parmi les dix workflows, quatre existent uniquement à cause de la conformité : facturx valide chaque PDF avec veraPDF et Mustang, render-golden compare le rendu à des références pour détecter un changement de mise en page involontaire, restore-drill répète la restauration de sauvegarde, et security-audit passe les dépendances au crible des advisories RustSec. Restaurer une sauvegarde, ça se teste aussi.

Questions fréquentes

NF203, c’est quoi techniquement pour un éditeur ?

Une facture émise ne peut plus être modifiée ni supprimée, sa numérotation est continue, et tu peux prouver les deux à un contrôleur. En code, ça veut dire une séquence verrouillée, un hash du contenu, une chaîne de hashs persistée, et une sauvegarde dont la restauration est testée.

Comment intégrer Factur-X dans un pipeline de génération de PDF existant ?

Il te faut un PDF/A-3 en sortie de ton moteur de rendu, un XML CII généré depuis les mêmes données, et une étape qui attache le XML et les métadonnées XMP au PDF. Ensuite, veraPDF et Mustang en CI, sinon tu ne sais pas ce que tu produis.

Une signature eIDAS simple suffit-elle pour un devis ou une facture ?

Pour une facture, la signature n’est pas obligatoire. Pour un devis, j’utilise le niveau simple et je garde la preuve du parcours : qui a consenti, quand, sur quel document. Sans preuve chaînée, c’est un bouton « J’accepte ».

Pourquoi Rust plutôt qu’une stack plus rapide à prototyper ?

Parce que j’avais déjà un produit qui marchait en Next.js, Deviso.pro, et que la contrainte n’était plus de prototyper mais de tenir. Le compilateur et sqlx m’empêchent de livrer une transaction incomplète ou une requête cassée. Mon avis : sur un module de facturation, ce filet vaut le temps de compilation.


Si ton produit facture pour le compte de tes utilisateurs et que tu veux poser la finalisation, Factur-X et les droits de tes agents avant d’écrire le premier PDF, c’est ce que je construis en SaaS sur mesure.

Un projet à concrétiser ?

Parlons-en. Je peux t'aider à transformer une idée en produit livré et itéré.

Un appel de 30 minutes, sans engagement. Je réponds sous 24 h. ★ Avis vérifiés sur Trustpilot