
Devisify : un SaaS de facturation multi-entreprises sans fuite de données
Il y a un bug qu'on ne rattrape jamais dans un logiciel de facturation : celui où un client voit la facture de quelqu'un d'autre.
Pas une erreur d'arrondi de TVA, pas un PDF mal aligné. Une ligne qui apparaît là où elle n'aurait jamais dû apparaître. Le jour où ça arrive, la confiance ne revient pas — et sur un outil qui manipule du chiffre d'affaires, des SIRET et des IBAN, elle est le produit.
Devisify (anciennement InvoicePro) est une plateforme de devis et factures multi-entreprises. Le sujet de cet article, ce n'est pas la facture. C'est la frontière : comment garantir qu'une donnée appartenant à une structure ne traverse jamais vers une autre, même quand un développeur pressé oublie une clause where.
La démo est ouverte, sans compte ni carte bancaire → facture.juliensaillot.fr Entreprises, clients, devis et factures pré-remplis. Vous pouvez créer un devis, le convertir, générer le PDF, basculer d'une structure à l'autre. Un bouton remet tout à zéro.
Devis et factures, multi-entreprises, fiches clients et PDF aux couleurs de chaque structure.
Le multi-entreprises n'est pas un confort, c'est une conséquence du droit
Quand j'ai commencé, je pensais que gérer plusieurs sociétés dans un même compte était un agrément. Un truc sympa pour les agences.
En lisant les règles françaises de facturation, j'ai compris que c'était une contrainte structurelle. L'article 242 nonies A de l'annexe II au Code général des impôts impose que chaque facture porte un numéro unique fondé sur une séquence chronologique et continue. Continue, c'est-à-dire sans trou : on ne supprime pas une facture, on n'en insère pas une après coup.
Deux structures juridiques distinctes ont donc, par nature, deux séquences qui ne se croisent jamais. Et le texte prévoit en plus qu'un même assujetti puisse tenir des séries distinctes lorsque les conditions d'exercice de son activité le justifient — typiquement plusieurs établissements ou plusieurs activités.
Traduction pour un développeur : la séquence appartient à la structure, pas au compte utilisateur. Un freelance qui a deux activités n'a pas « un compte avec deux dossiers ». Il a deux jeux de mentions légales, deux numérotations indépendantes, deux identités visuelles, deux comptes bancaires. La réponse habituelle des logiciels de facturation — « créez un deuxième compte » — n'est pas une simplification, c'est un renvoi du problème vers l'utilisateur.
Donc : multi-entreprises natif, dès le modèle de données. Et à partir de là, tout le reste de l'architecture découle d'une seule question.
La question qui structure tout : à qui appartient cette ligne ?
Dès qu'un compte peut contenir plusieurs entreprises, chaque enregistrement de la base — client, devis, facture, projet, ligne de facture, pièce jointe — doit répondre à cette question sans ambiguïté. En pratique, company_id descend jusqu'aux tables feuilles.
Et là apparaît la classe de bugs la plus coûteuse en confiance d'un SaaS multi-tenant : la fuite inter-locataires. Elle ne ressemble jamais à une faille spectaculaire. Elle ressemble à ça :
// Le bug tient dans ce qui manque.
const invoices = await db.from('invoices').select('*').eq('client_id', clientId)
La requête est correcte, elle passe la revue de code, elle passe les tests — parce que dans un jeu de test à une seule entreprise, il n'y a rien à fuir. Elle casse le jour où un utilisateur en a deux.
Mon parti pris a été de considérer que cette faute finirait par être commise. Donc de faire en sorte qu'elle ne suffise pas.
Trois couches, et la dernière est celle qui compte
Une requête les traverse dans cet ordre. Chaque couche suppose que la précédente a échoué.
Couche 1 — Les guards NestJS
Une requête vers /api/companies/:id/invoices portant sur une entreprise dont on n'est pas membre doit être refusée en 403, pas renvoyer une liste vide. Deux guards se chaînent : AuthGuard valide la session et injecte l'utilisateur, CompanyAccessGuard vérifie l'appartenance à l'entreprise ciblée avant que le contrôleur ne s'exécute.
@UseGuards(AuthGuard, CompanyAccessGuard)
@Get('companies/:companyId/invoices')
findAll(@Param('companyId') companyId: string, @CurrentUser() user: User) {
return this.invoicesService.findAll(companyId, user)
}
Les jetons de session vivent dans des cookies httpOnly, inaccessibles au JavaScript de la page — la première défense contre le vol de session par XSS. Contrepartie qu'on oublie systématiquement en choisissant les cookies plutôt qu'un en-tête Authorization : le navigateur les envoie tout seul, y compris depuis un autre site. Il faut donc SameSite=Lax au minimum, et une protection CSRF sur toutes les routes qui écrivent. Un cookie httpOnly sans SameSite, c'est une faille échangée contre une autre.
Couche 2 — Rôles et validation stricte
À l'intérieur d'une entreprise, tout le monde n'a pas les mêmes droits : owner > admin > member. Le propriétaire gère la structure et les membres, l'administrateur gère l'activité, le membre travaille. L'ajout d'un collaborateur passe par une invitation par email avec jeton à usage unique et expiration — pas par la création d'un compte partagé.
Les entrées sont validées par class-validator configuré en whitelist et forbidNonWhitelisted : un champ non déclaré dans le DTO n'est pas ignoré, il fait échouer la requête. C'est ce qui empêche un role: "owner" glissé dans un corps JSON d'arriver jusqu'à la base.
Couche 3 — Le Row Level Security PostgreSQL
C'est la couche la plus profonde, et la seule qu'on ne peut pas contourner par oubli. Elle ne vit pas dans le code applicatif : elle vit dans la base, et elle s'applique à toute requête, quelle qu'en soit l'origine.
alter table invoices enable row level security;
create policy "acces limite aux entreprises de l'utilisateur"
on invoices for select
using (company_id in (select public.company_ids_of_current_user()));
La requête bugguée du paragraphe précédent, celle sans filtre d'entreprise, ne renvoie plus les factures des autres. Elle renvoie zéro ligne. Le développeur voit un résultat vide, comprend son erreur, corrige. Le bug devient un désagrément au lieu d'un incident.
Deux pièges m'ont coûté du temps, et ils méritent d'être écrits noir sur blanc :
Le premier, c'est la clé service_role. Elle court-circuite toutes les politiques, par conception. Un backend qui l'utiliserait partout pour « simplifier » désactiverait silencieusement l'intégralité de la sécurité qu'il croit avoir. Règle que je me suis fixée : le jeton de l'utilisateur est propagé jusqu'à Postgres pour toutes les opérations métier, et la clé privilégiée est réservée à quelques opérations d'administration explicitement identifiées.
Le second, c'est la récursion. La politique ci-dessus doit savoir de quelles entreprises l'utilisateur est membre — donc lire la table company_members. Mais company_members est elle-même protégée par RLS, dont la politique relit company_members… et Postgres coupe court en infinite recursion detected in policy. La sortie propre est d'extraire cette lecture dans une fonction security definer, qui s'exécute avec les droits de son propriétaire et n'est donc pas re-soumise à la politique appelante.
create function public.company_ids_of_current_user()
returns setof uuid
language sql
security definer
set search_path = public
as $$ select company_id from company_members where user_id = auth.uid() $$;
C'est le genre de détail qu'on ne trouve pas dans un tutoriel de démarrage, et qui décide si le RLS protège vraiment quelque chose.
Devis → facture : le moment où le logiciel doit dire non
Le parcours produit tient en quatre étapes, et la démo les enchaîne en moins de deux minutes : créer un devis (≈ 30 secondes), l'envoyer par email avec son PDF, le convertir en facture en un clic, suivre le paiement.
La conversion est la partie intéressante. Un devis est une proposition : on le modifie, on le duplique, on le supprime. Une facture émise est un document comptable : elle ne se supprime pas. Si elle est fausse, on émet une facture d'avoir. Jamais de DELETE.
Le logiciel doit donc changer de comportement à cet instant précis, et le rendre visible :
| Devis | Facture | |
|---|---|---|
| Modification | libre | verrouillée |
| Suppression | possible | jamais — avoir uniquement |
| Numérotation | série DEV-… | série INV-…, chronologique et continue |
| Statuts | brouillon → envoyé → accepté / refusé | brouillon → envoyé → payé / en retard |
Un écart que j'assume plutôt que de le masquer : aujourd'hui, le verrouillage se déclenche au statut « payée », alors que le droit fige la facture dès son émission. Le comportement correct est de basculer le verrou au passage en « envoyé », et de n'autoriser ensuite que l'avoir. C'est en haut de ma liste.
La numérotation continue, ou pourquoi une séquence Postgres ne suffit pas
C'est le problème technique le plus intéressant du projet, et on ne le voit pas venir en développement solo : deux utilisateurs de la même entreprise qui valident une facture au même instant ne doivent pas obtenir le même numéro. Lire le dernier numéro puis écrire le suivant, c'est une condition de course — et un doublon dans une séquence légalement continue est un problème comptable, pas un bug d'affichage.
Le réflexe naturel est d'utiliser une SEQUENCE PostgreSQL. Elle règle la concurrence… et casse l'autre moitié de l'exigence : une séquence n'est pas transactionnelle. Un nextval suivi d'un rollback consomme quand même le numéro. Résultat : un trou dans la numérotation, exactement ce que la loi interdit.
L'approche qui tient : une ligne compteur par entreprise, verrouillée le temps de la transaction, et un numéro attribué le plus tard possible — au moment de l'émission, pas à la création du brouillon.
-- Verrou de ligne : les transactions concurrentes attendent, aucune ne saute de numéro.
update invoice_counters
set last_number = last_number + 1
where company_id = $1
returning last_number;
Un index unique sur (company_id, number) ferme la porte au cas résiduel. Et parce que le numéro n'est attribué qu'à l'émission, un brouillon abandonné ne laisse aucun trou derrière lui.
C'est le genre de contrainte que j'aime dans ce projet : elle vient du droit, elle se traduit en schéma SQL, et elle interdit des choses que l'interface aurait naturellement permises.
Le PDF est le produit
Personne n'ouvre un logiciel de facturation pour l'admirer. On l'ouvre pour produire un fichier qu'on envoie à un client. Pour beaucoup de destinataires, ce PDF est le seul document qu'ils verront de l'entreprise ce mois-ci. Il ne peut pas avoir l'air d'un export par défaut.
D'où un module de personnalisation complet :
- quatre gabarits — Modern, Corporate, Creative, Elegant ;
- logo et filigrane téléversés, opacité réglable ;
- palette de quatre couleurs par entreprise ;
- prévisualisation en direct, chaque modification se reflète immédiatement.
La génération se fait avec @react-pdf/renderer : le document se décrit en composants React, ce qui permet de partager la mise en page entre l'aperçu à l'écran et le fichier produit — un seul endroit à corriger quand une mention légale change.
Et parce qu'on est en multi-entreprises : la charte est attachée à l'entreprise, pas au compte. Basculer de structure change le logo, les couleurs, les mentions légales et l'IBAN, sans rien à reconfigurer.
Pourquoi un backend NestJS alors que Supabase suffirait
Question légitime : Supabase expose déjà une API REST, gère l'authentification et applique le RLS. Un front Next.js peut lui parler directement. Pourquoi ajouter une application NestJS entre les deux ?
Réponse honnête : pour ce qui ne rentre pas dans une requête.
- Envoyer un email avec le PDF en pièce jointe, via Nodemailer, sans exposer les identifiants SMTP au navigateur.
- Convertir un devis en facture : une transaction qui lit, écrit et attribue un numéro de séquence — pas un
insert. - Émettre des invitations : générer un jeton, l'envoyer, l'expirer.
- Calculer des statistiques agrégées sans télécharger l'historique complet côté client.
- Valider strictement les entrées, en un seul endroit, avec des DTO typés.
Ce qui donne une répartition simple. Supabase reste le socle : Postgres, Auth, Storage, RLS. NestJS porte les opérations métier, les effets de bord et les secrets. Next.js affiche.
L'application est organisée en modules explicites (auth, companies, clients, finances/invoices, finances/quotes, finances/stats, invitations, invoice-customization, mail), toutes les routes préfixées par /api, et les deux applications sont conteneurisées en Docker multi-stage.
FROM node:20-alpine AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /usr/src/app/dist ./dist
EXPOSE 3334
CMD ["node", "dist/main"]
Côté front : Next.js et React pour l'application, Tailwind CSS avec Radix UI et shadcn/ui pour le design system, Recharts pour les graphiques du tableau de bord, Framer Motion pour les animations de la vitrine.
C'est la même logique de séparation que sur Calepinify, mon autre projet : mettre chaque responsabilité là où elle est le moins facile à contourner.
Ce que vous verrez vraiment dans la démo
La démo tourne en mode démonstration : les entreprises, clients, devis et factures sont générés localement, vos modifications restent dans votre navigateur, et un bouton « Réinitialiser » remet le jeu de données à l'état initial. Aucune inscription, aucune carte bancaire.
Autant être direct sur un point : les chiffres affichés sur la page d'accueil sont des chiffres de vitrine. Volume traité, entreprises actives, taux de satisfaction — ils illustrent une mise en page, ils ne mesurent rien. Devisify est un projet personnel, pas une entreprise avec un portefeuille de clients. Je préfère l'écrire ici que laisser un lecteur attentif se poser la question.
Ce qui est réel, en revanche, et vérifiable en trois clics : le multi-entreprises et le basculement de contexte, les fiches clients avec leur historique et leurs statistiques, le cycle devis → facture, les gabarits de PDF personnalisables, le tableau de bord et ses graphiques, le modèle de rôles.
Questions fréquentes
Pourquoi gérer plusieurs entreprises dans un même logiciel de facturation ?
Parce qu'une numérotation de factures appartient à une structure juridique, pas à une personne. Un freelance avec deux activités, une agence avec plusieurs sociétés ou un cabinet multi-établissements doivent tenir des séquences séparées. Les faire cohabiter dans un même compte évite de dupliquer les abonnements — à condition que le cloisonnement soit réel.
Qu'est-ce que le Row Level Security de PostgreSQL ?
C'est un mécanisme qui filtre les lignes visibles au niveau de la base, selon des politiques écrites en SQL. Contrairement à un filtre applicatif, il s'applique à toutes les requêtes, y compris celles qui ont oublié leur clause where. C'est la brique centrale de l'isolation multi-tenant sur Supabase.
Peut-on modifier ou supprimer une facture déjà émise ?
Non. La réglementation impose une numérotation chronologique et continue : supprimer une facture créerait un trou. Une facture erronée se corrige par une facture d'avoir, qui annule tout ou partie de la première sans la faire disparaître.
Qu'est-ce que la réforme de la facturation électronique change en 2026 ?
À partir du 1ᵉʳ septembre 2026, toutes les entreprises assujetties à la TVA doivent pouvoir recevoir une facture électronique, et les grandes entreprises comme les ETI doivent l'émettre. Les PME, TPE et micro-entreprises suivent au 1ᵉʳ septembre 2027. Un PDF envoyé en pièce jointe d'un email ne suffira plus.
Ce que j'en retire
Je suis en Bachelor 3, orienté cybersécurité et développement. Devisify est le projet où ces deux fils se rejoignent : une application de gestion racontée comme un problème de cloisonnement.
La sécurité est une décision d'architecture, pas une étape de fin de projet. Ajouter du RLS sur un modèle de données qui n'a pas été pensé multi-tenant est un chantier douloureux. L'avoir posé dès le premier schéma n'a coûté qu'une colonne et quelques politiques SQL.
Concevoir en supposant sa propre erreur. Je n'ai pas écrit ces trois couches parce que je me méfie des attaquants, mais parce que je me méfie de moi à 23 h un dimanche. Les défenses qui tiennent sont celles qui n'exigent pas de se souvenir de quelque chose.
Les contraintes légales sont des spécifications techniques. « Séquence chronologique continue » n'est pas une phrase de juriste à ranger dans un coin : c'est un verrou de ligne, un index unique, un numéro attribué le plus tard possible et une interdiction de DELETE. Savoir faire cette traduction, c'est une bonne partie du métier de développeur d'applications de gestion.
Je cherche une alternance en développement, cybersécurité ou réseaux à Montpellier. L'architecture et les arbitrages décrits ici se discutent volontiers en entretien — la démo est ouverte, il suffit de cliquer.
Lancer la démo → · Me contacter · GitHub · LinkedIn
La suite : la facturation électronique, dans quelques jours
Ce n'est pas une feuille de route théorique. Le calendrier est fixé, et la première échéance tombe le 1ᵉʳ septembre 2026 — dans moins de deux semaines au moment où j'écris ces lignes. À cette date, toutes les entreprises assujetties à la TVA doivent être capables de recevoir une facture électronique ; les grandes entreprises et les ETI doivent aussi l'émettre. Les PME, TPE et micro-entreprises émettront à partir du 1ᵉʳ septembre 2027.
Concrètement, un PDF envoyé en pièce jointe d'un email ne sera plus une facture électronique au sens de la réforme. Il faudra passer par une plateforme agréée et produire un format structuré, lisible par une machine.
Pour Devisify, la trajectoire est donc claire :
Verrouillage à l'émission — aligner le comportement sur le droit avant tout le reste. Format Factur-X — un PDF qui embarque ses propres données en XML, lisible par un humain comme par un logiciel comptable. Connexion à une plateforme agréée — le canal de transmission et de récupération des statuts. Paiement en ligne — relier le statut « payé » à un encaissement réel plutôt qu'à une case cochée à la main. Export comptable — livrer à l'expert-comptable un fichier qu'il peut importer sans le retaper.
Stack : Next.js · React · TypeScript · Tailwind CSS · Radix UI / shadcn/ui · Framer Motion · Recharts · @react-pdf/renderer · NestJS · class-validator · Nodemailer · Supabase (PostgreSQL, Auth, Storage, RLS) · Docker multi-stage Sujet technique central : isolation de données multi-tenant, Row Level Security, modèle de rôles, contraintes légales de facturation
Sources : Article 242 nonies A, annexe II au CGI — Légifrance · Tout savoir sur la facturation — service-public.gouv.fr · Réforme de la facturation électronique, calendrier — KPMG
