Une application de facturation écrite pour se conformer soi-même à l'obligation française. Ce qu'elle démontre n'est pas une liste de fonctions, mais une position de conception, et le chemin qui y mène.
Avant d'écrire une ligne, il a fallu défaire ce que la documentation ambiante répète. Les trois corrections ci-dessous ne sont pas des détails de vocabulaire : chacune change ce que le logiciel doit faire.
| Ce qu'on lit partout | Ce qui est vrai |
|---|---|
| « Logiciel homologué », « facture certifiée » | Il n'existe aucune étape d'homologation ni de signature d'une facture par un logiciel de facturation. Ce qui est réglementé, c'est la transmission par une plateforme agréée1. Un moteur appelé en local ne transmet rien et n'apporte donc aucune conformité. L'attestation prévue par l'art. 286-I-3° bis du CGI est autre chose : elle vise les systèmes de caisse, pour des ventes à des particuliers2. |
| « Envoi via Chorus Pro » | Chorus Pro est le portail B2G, celui de la facturation vers la sphère publique. Ce n'est pas une plateforme agréée pour le B2B, et les confondre fait concevoir le mauvais raccordement. |
| « Franchise en base : une case à cocher » | C'est un régime daté, avec deux seuils aux effets distincts : 37 500 € fait basculer au 1er janvier suivant, 41 250 € le jour même3. Le régime doit donc être résolu à la date du document et figé dedans, jamais recalculé à l'affichage. |
La troisième a une conséquence que peu d'outils tirent : le chiffre d'affaires d'une micro-entreprise se compte à l'encaissement, pas à la facturation. Le livre des recettes et la détection de franchissement de seuil s'ordonnent donc sur les règlements, et non sur les factures émises.
La tentation, quand on lit « inaltérabilité », est d'interdire toute modification dès qu'un document est émis. C'est ce que faisait la première version : écran grisé, champs verrouillés, correction impossible.
C'était une erreur, et elle a été corrigée par l'utilisateur de l'application, qui est aussi celui qui facture :
« Ce qu'il faut figer, c'est la chaîne. Le contenu, lui, peut être sujet à erreur humaine, donc à une réécriture. »
La correction qui a redéfini le modèle
L'inaltérabilité comptable n'a jamais voulu dire qu'on ne corrige pas. Elle veut dire qu'on ne corrige pas en douce. Un projet fait des allers-retours : on envoie un devis, le client demande un ajustement, on renvoie. Interdire la correction ne supprime pas l'erreur de saisie : elle pousse à créer un second document, c'est-à-dire à casser la numérotation pour préserver une immobilité qui n'était pas demandée.
| Figé : la chaîne | Réécrivable : le contenu |
|---|---|
| Le numéro attribué, jamais rendu ni réutilisé | Montants, descriptions, lignes |
| La continuité des trois séries | Coordonnées du client |
| L'existence du document | Dates, objet, conditions de paiement |
| L'historique des modifications, en ajout seul |
Chaque frontière est un fait objectif que la base connaît déjà. Aucune ne dépend d'un jugement, d'un réglage ou d'un statut qu'on pourrait poser à tort.
La forme encode l'état : pointillé pour ce qui n'engage rien, trait plein pour ce qui est acquis, aplat pour ce qui est scellé.
Un trou dans la numérotation. Un brouillon n'a consommé aucun numéro : l'effacer ne laisse rien. Un numéro attribué ne se rend jamais. Il n'y a donc aucun état d'où une rupture puisse naître.
L'article 242 nonies A de l'annexe II au CGI impose à chaque facture un numéro unique fondé sur une séquence chronologique et continue, sans rupture4. Un trou doit pouvoir s'expliquer. Le texte admet des séries distinctes quand l'activité le justifie, ce qui fonde les trois séries tenues séparément ici.
Numéroter dès la création fabrique des trous en usage normal : on
attribue FAC-0007, on corrige en supprimant et en
refaisant, le 7 manque. Pas une fois par accident : à chaque
correction.
Un brouillon porte BROUILLON-<id> et ne consomme
rien. Quatre chemins peuvent l'émettre : émission explicite,
changement de statut, facturation d'un devis, premier encaissement. Tous passent par la même porte.
Tant que les règles vivent dans le programme, il suffit d'ouvrir le fichier de données avec n'importe quel outil pour passer à côté. Seize règles ont donc été descendues dans la base elle-même : elles s'appliquent quoi qu'on utilise pour y écrire, y compris un outil qui ignore tout de l'application.
-- Un document encaissé ne change plus. Avant l'encaissement,
-- le contenu se corrige : c'est l'aller-retour normal d'un projet.
CREATE TRIGGER verrou_document_fige
BEFORE UPDATE ON documents
WHEN EXISTS (SELECT 1 FROM paiements p WHERE p.document_id = OLD.id)
AND (OLD.montant_ht IS NOT NEW.montant_ht OR …)
BEGIN
SELECT RAISE(ABORT,
'Document encaissé : son contenu ne change plus. '
|| 'Le corriger par un avoir.');
END;
Un coffre-fort. Qui peut écrire dans le fichier peut aussi en retirer les règles. Ce qu'elles arrêtent, c'est l'erreur et la retouche désinvolte, pas un effacement déterminé. La preuve qu'on pourrait opposer à un contrôle, c'est le chaînage d'empreintes : chaque document scellé porte une signature calculée à partir de celle du précédent, si bien qu'en retirer un seul rompt la suite de façon visible. Il n'est pas encore fait.
Une facture émise en franchise en base porte la mention « TVA non applicable, art. 293 B du CGI ». Si le régime bascule six mois plus tard, cette facture doit continuer à l'afficher : elle était juste au moment où elle a été émise.
Le régime et les mentions légales sont donc écrits dans le document à sa création, au même titre que les coordonnées du client. Rien n'est recalculé à l'affichage.
Cette décision va être mise à l'épreuve : la recodification de la TVA dans le code des impositions sur les biens et services remplace l'art. 293 B du CGI par l'art. L. 223-3 du CIBS, au 1er janvier 20278. Les factures émises avant cette date devront continuer d'afficher l'ancienne référence, celles émises après la nouvelle. Une mention figée par document traverse ce changement sans rien réécrire ; une mention recalculée à l'affichage aurait faussé rétroactivement toutes les factures antérieures.
Un écran de contrôle, écrit pour voir ce qui est réellement enregistré, a révélé que le régime de TVA restait vide sur toute facture issue d'un devis. Ces factures se seraient imprimées sans aucune mention légale, et rien ne l'aurait laissé voir à l'écran.
Nous nous appuyons sur les méthodes d'Odoo Community pour rendre notre code plus fiable. Le modèle décrit plus haut est le leur : mêmes états, même point de gel, mêmes raisons. Il tourne dans un logiciel comptable utilisé par des milliers d'entreprises.
Les trois correspondances ci-dessous sont lues dans le source, numéros de ligne à l'appui.
| Notre décision | Odoo 18.0 | Source |
|---|---|---|
| Brouillon sans numéro, numéro à l'émission |
name = '/' tant que l'écriture n'est pas
validée ; _set_next_sequence() n'est appelé que
si state != 'draft' |
account_move.py :927-939 |
| Chaînage d'empreintes pas encore fait |
sha256(previous_hash + current_record), stocké
dans inalterable_hash, indexé par
secure_sequence_number |
account_move.py :4204-4236 |
| Retour en arrière possible tant que rien n'est scellé |
show_reset_to_draft_button = not inalterable_hash
and (state == 'cancel' or posted) |
account_move.py :1789-1796 |
La troisième est la plus instructive. La règle « on peut revenir en arrière tant que rien n'est scellé » est celle d'Odoo, au mot près. Elle dit quelque chose que l'intuition contredit : le gel arrive à un événement, jamais à l'émission. S'aligner sur un logiciel qui a déjà tranché cette question évite de la retrancher soi-même, et mal.
Odoo pose la même règle, mais dans son programme, pas dans sa base : quand un document est scellé, toute tentative de modification est arrêtée par un contrôle écrit en Python.
violated_fields = set(vals).intersection(
move._get_integrity_hash_fields() + ['inalterable_hash'])
if move.inalterable_hash and violated_fields:
raise UserError(_(
"This document is protected by a hash. "
"Therefore, you cannot edit the following fields: %s.", …))
account_move.py:3379-3385
La différence se voit dès qu'on n'utilise plus Odoo pour écrire : son contrôle ne s'applique plus, celui inscrit dans la base tient toujours.
Ce n'est pas « mieux ». Odoo a de bonnes raisons : il doit fonctionner au-dessus de plusieurs types de bases de données, et ses milliers de modules complémentaires étendent les règles en passant tous par le même endroit. Les enfouir dans la base les rendrait invisibles à ces modules. C'est un arbitrage. Savoir lequel on prend, et ce qu'on paie pour lui, vaut mieux que d'avoir raison.
Intégrer Odoo sur ce terrain, ce n'est pas y déverser des règles comptables : c'est savoir où elles vivent déjà. La numérotation, le scellement et la liste des champs protégés existent, chacun à un endroit précis du code. Un développement qui ignore ces points de passage réécrit ce qui est déjà là, et finit par le contredire.
Le modèle à trois crans n'a pas été dessiné pour Odoo. Il s'y emboîte quand même, terme à terme, et c'est ce qui rend un raccordement envisageable sans rien tordre.
| Ici | Odoo | Le point de jonction |
|---|---|---|
| Brouillon | state = 'draft', name = '/' |
Rien à transmettre : le document n'existe pour personne |
| Émission | action_post() |
C'est le même instant : celui où le numéro est attribué et où la pièce part |
| Encaissement | Sécurisation, inalterable_hash posé |
Le gel, des deux côtés |
Une intégration consisterait donc à pousser le document dans
account.move au moment de l'émission, pas à le convertir ni à le rejouer. Les deux modèles sont déjà d'accord sur
quand un document cesse d'être un projet.
Il faut être exact : une facture est valide ou elle ne l'est pas. Elle l'est si elle porte les mentions obligatoires, un numéro continu, si elle est émise en temps voulu et conservée. Aucun logiciel n'ajoute de la validité à une facture qui l'a déjà, et aucun n'en donne à une facture qui ne l'a pas.
Ce qui changerait vraiment est ailleurs, et c'est substantiel :
l10n_fr_pdp)5. Se raccorder
à Odoo, c'est donc se raccorder à une PA, précisément ce qu'aucune
application isolée ne peut fournir.
account_edi_ubl_cii, en Community : export et
import CII, PDF embarqué. Notre chantier 4, écrit et suivi par
d'autres.
account,
avec son rapport d'intégrité et sa détection de rupture.
Être une plateforme agréée, ce qu'Odoo est, signifie être immatriculé pour émettre, recevoir et transmettre des factures électroniques. Attester de la conformité d'un logiciel est autre chose : l'attestation que publie Odoo SA porte sur son logiciel et concerne le régime des systèmes de caisse, pour des ventes à des particuliers2. Ni l'une ni l'autre ne « certifie » une facture prise isolément.
Et le raccordement se paie en dépendance : le module
l10n_fr_pdp n'est pas présent dans ce dépôt Community
18.0, et son enrôlement depuis une instance auto-hébergée suppose un
compte côté Odoo6. Ce n'est pas une
bibliothèque qu'on installe : c'est un service auquel on s'abonne.
Un devis de refonte, trois phases, dix mille euros. Ce qu'il faut regarder n'est pas ce qui s'affiche, mais ce qui ne se passe pas.
# Le devis existe, il est complet, il est chiffré.
POST /api/documents
→ { "ref": "BROUILLON-1", "numerote": false, "montant_ht": 10025.0 }
# Et pourtant :
séquences : devis=0, facture=0, avoir=0
révisions : 0
supprimable : oui
Aucun numéro consommé. Aucune trace à conserver : le document n'est
jamais sorti. Il peut être jeté sans laisser de rupture. Il prendra
DEV-0001 au moment de l'émission, pas avant.
| Étape | Ce qui change |
|---|---|
| Émission du devis | DEV-0001 attribué ; le devis ne se supprime plus |
| Facturation | Total, acompte ventilé par taux de TVA, ou solde avec déduction des acomptes déjà émis |
| Encaissement | La facture se fige ; le montant entre au livre des recettes à sa date de règlement |
| Franchissement de seuil | Détecté au moment de l'encaissement : un événement daté et réversible, jamais une bascule automatique du régime |
| Factur-X | PDF/A-3 avec XML CII EN 16931 embarqué, profil en16931, refusé sur un document non émis |
Une facture de solde sortait à 5 876 € au lieu de 3 164 € dans le fichier électronique, alors qu'elle affichait le bon montant à l'écran. Les lignes qui déduisent un acompte déjà versé sont légitimement négatives, et le programme les remettait au positif. Seul un avoir doit voir ses signes inversés ; une facture garde les siens.
En tant que produit, c'est incomplet, et ça le restera un moment. Voici ce qui manque, et ce qui ne peut pas exister aujourd'hui.
Sur les quatorze chantiers identifiés, huit sont faits. Les six qui restent ne sont pas des oublis : quatre dépendent d'un raccordement externe, et les deux autres conditionnent la première utilisation réelle. Le document d'architecture les tient à jour, avec ce qui les bloque.
Le travail difficile, ici, n'a pas été écrit. Le format Factur-X, le PDF/A-3, les clés de contrôle SIRET, le moteur de base de données et ses déclencheurs : tout cela existait, éprouvé et maintenu par d'autres. Réécrire l'un de ces morceaux aurait été long, moins sûr, et sans le moindre intérêt.
| Couche | Choix | Raison |
|---|---|---|
| Interface | HTML / CSS / JS natif | Aucune dépendance, aucune étape de construction |
| API | Flask 3.1 | Démarrage instantané, surface minimale |
| Données | SQLite | Un fichier ; la sauvegarde est une copie |
| Factur-X | factur-x + reportlab | La première embarque un XML dans un PDF, elle n'en produit aucun, d'où la seconde, qui dessine la facture lisible |
| Contrôles | python-stdnum | Clés SIRET et SIREN, sans appel réseau |
Les données restent entièrement locales. Le raccordement à une plateforme agréée n'est pas une couche de cette pile : c'est un service externe, et c'est le seul moment où quoi que ce soit sortira de la machine.
Les règles, et elles seules. Quand un numéro s'attribue et quand il cesse de pouvoir changer. Ce qu'un document peut encore devenir selon qu'il est réglé ou non. À quelle date se résout un régime de TVA et pourquoi il se fige dans la pièce. Quel signe porte une ligne de déduction d'acompte.
Aucune de ces règles n'est une invention : elles sont dans les textes, et un logiciel comptable existant les applique déjà. Ce qui se joue n'est pas de les créer, c'est de les lire correctement, puis de les placer là où elles tiennent.
Consultées le 9 septembre 2026. Les références au code d'Odoo ont été lues dans le dépôt, branche 18.0 : les numéros de ligne renvoient à cette branche et bougeront avec elle.
l10n_fr_pdp (« France -
Facturation électronique (plateforme agréée) ») porte le
raccordement. La liste officielle des PA est publiée par
l'administration.
Odoo, annonce d'immatriculation · impots.gouv.fr, liste des plateformes agréées
l10n_fr_pdp est absent du
dépôt Community 18.0 utilisé ici, qui ne contient que
l10n_fr, l10n_fr_account,
l10n_fr_facturx_chorus_pro et
l10n_fr_pos_cert. Des utilisateurs auto-hébergés
rapportent un refus d'enrôlement auprès du service.
Constat direct sur le dépôt · Forum Odoo, « Registration denied » en Community auto-hébergé