Aller au contenu

Blog ·

Quinze choses qu’une migration de facturation ne peut pas emporter

Une migration de facturation n’est pas une copie de données. Quinze omissions mesurées, l’argent en centimes entiers et l’empreinte comme seule clé utile.

Par Provibr

Déplacer une activité d’hébergement d’un panneau de facturation vers un autre ressemble à une copie de données et n’en est pas une. Deux panneaux qui savent tous les deux ce qu’est un client, un service et une facture restent en désaccord sur ce que contiennent ces objets, et ce désaccord n’est jamais symétrique : la source en sait généralement plus que ce que l’API de la cible accepte de recevoir. Une migration qui fait comme si de rien n’était écrit des documents qui ne correspondent pas à ceux que le client a déjà reçus.

La première chose que produit cet outil n’est donc pas de la donnée. C’est une liste de ce qui n’arrivera pas.

L’argent est en centimes entiers, et la conversion lit du texte

Les deux panneaux livrent un montant sous forme de nombre JSON (9.95) ou de texte décimal ("9.95"). La conversion évidente consiste à multiplier par cent.

La conversion évidente est fausse. En virgule flottante 64 bits, 9,95 vaut 9,949999999999999289, et 9.95 * 100.0 donne 994,9999999999999. Tronqué, cela fait 994 centimes : un centime de moins que ce que dit la facture. Sur un serveur de jeu, c’est un désagrément d’arrondi. Sur une comptabilité, c’est un document inexact, et cela ressort quand quelqu’un clôture l’exercice.

Le modèle canonique porte donc un montant sous forme d’un nombre entier de centimes plus un code de devise, et la conversion compte les chiffres de la forme textuelle au lieu de passer par un flottant. Au-delà de deux décimales, l’arrondi s’éloigne de zéro plutôt que de tronquer, car un demi-centime tronqué fait disparaître une ligne de facture. La notation scientifique est refusée d’emblée : gérer 1e2 à moitié transformerait une facture de cent euros en un centime, et refuser coûte moins cher que d’être malin. Il existe une borne supérieure de 2^53 centimes, parce que l’autre bout du fil compte en nombres JavaScript, et une valeur que nous acceptons et qu’ils ne représentent plus exactement est un écart silencieux plutôt qu’une erreur.

Quinze omissions, chacune mesurée

Huit valent pour toute migration de facturation, et sept sont propres à la première route, de FOSSBilling vers Paymenter. Les quinze ont été mesurées contre des conteneurs en marche et non reprises d’une documentation.

Les huit générales sont celles que l’on devine dès qu’on les énonce. Les mots de passe clients sont des empreintes calculées avec la clé applicative de la source et ne fonctionneraient nulle part ailleurs, et le point d’entrée utilisateurs de la cible exige un nouveau mot de passe d’au moins huit caractères. Les cartes et mandats enregistrés sont des jetons appartenant au compte du panneau source chez le prestataire de paiement ; les emporter reviendrait à faire partir un prélèvement depuis un compte que le client n’a jamais signé. La configuration des passerelles porte des clés et des adresses de webhook du vieux panneau, elle n’est donc délibérément jamais lue. Les modèles de courriel sont écrits dans le langage de gabarit de leur propre panneau, et un modèle repris produit un courrier avec des marqueurs non substitués. Les pièces jointes des tickets existent comme fichiers sur le disque du panneau source, sans point d’entrée qui en rende le contenu. La comptabilité d’affiliation n’a pas d’équivalent de même sens, et l’emporter ferait renaître des commissions déjà versées. Les codes de réduction privés de leur historique d’utilisation sont franchement faux : un code limité à dix usages déjà employé quatre fois arrive comme un code auquel il en reste dix. Les secrets de double authentification sont chiffrés par panneau et ne sont jamais restitués.

Les sept propres à la route sont la moitié intéressante, car ce sont toutes des limites de l’API d’administration de la cible et non de la cible elle-même.

Ce qui reste en arrièrePourquoi, mesuré
Société, adresse, numéro fiscal, téléphone, langueLa requête de création valide prénom, nom, courriel, mot de passe, date de vérification et rôle, et le contrôleur crée exactement l’ensemble validé. Tout le reste tombe.
Numéros de factureinvoices.number est unique en base mais n’apparaît dans aucune des deux règles de validation ni dans aucune ressource ; il n’est ni écrivable ni relisible.
La taxe sur une ligneUne ligne de facture accepte description, quantité, prix et une référence. Il n’y a pas de champ de taxe.
Le catalogue de produitsLe contrôleur produits n’a que index et show, et le fichier de droits n’accorde que view sur les produits et les catégories.
Les écritures de paiementL’API ne publie aucun point d’entrée pour les paiements ni les transactions.
Un service déjà échuLa date d’expiration est validée comme aujourd’hui ou plus tard.
Les identités du personnelUn message de ticket exige un user_id qui existe comme utilisateur, et le personnel de la source n’est pas utilisateur de la cible.

Deux de ces lignes portent une décision qui mérite d’être nommée.

Les numéros de facture partent vers stderr. Comme la cible attribue sa propre série et n’accepte ni ne rend l’ancien numéro, le migrateur écrit une ligne par facture sur la sortie d’erreur avec le numéro source, là où le journal de l’agent le récupère. Ce n’est pas élégant. C’est honnête : le numéro figure sur un document que le client a en main, et le seul endroit où nous pouvons encore le mettre est un journal que l’exploitant maîtrise.

Une facture avec taxe est bloquée plutôt qu’écrite. La cible calcule la taxe depuis ses propres réglages, la même facture y obtiendrait donc un total différent du document déjà envoyé. L’écrire quand même produit un second enregistrement contradictoire d’une seule opération. Le plan range ces factures sous tax_not_carried et poursuit.

Cette distinction traverse tout l’outil. Un produit manquant bloque le service au lieu de le classer ailleurs. Bloquer est un résultat ; deviner ne l’est pas.

La source répond HTTP 200 quand elle échoue

L’API d’administration de FOSSBilling répond à un appel raté par un statut 200 et un corps de {"result":null,"error":{"message":"...","code":9999}}. Qui ne regarde que le code lit un échec comme une réponse vide, et une migration incapable de distinguer vide de échoué finira un jour par synchroniser une comptabilité jusqu’au vide. Une seule fonction ouvre cette enveloppe, et elle regarde error avant result.

Le lecteur est par ailleurs en lecture seule par construction, et c’est un test plutôt qu’une promesse. Chaque méthode qu’il peut appeler est une constante nommée finissant par get ou get_list, sept en tout, et un test lit le fichier source et confronte chaque appel client.get( à cette liste. Un lecteur qui gagnerait un appel _create serait un test rouge et non une découverte après coup.

Autre bizarrerie mesurée : un produit FOSSBilling revient avec les sept périodes de facturation cochées et à prix zéro, quoi que vende réellement le panneau. Le prix est le seul signal exploitable pour savoir quel cycle est réel.

Idempotence sur une empreinte, faute de clé externe

La manière habituelle de rendre une migration reprenable est d’écrire son propre identifiant sur la cible et de le rechercher la fois suivante. Paymenter n’offre nulle part où le mettre : aucun champ de référence externe ne survit à une écriture et ne revient à la lecture.

L’identité est donc une empreinte calculée à partir du contenu des deux côtés.

  • Un client, c’est son adresse de courriel en minuscules.
  • Un service, c’est courriel, nom de produit, prix en centimes, devise et quantité.
  • Une facture, c’est courriel, devise, date d’échéance et ses lignes, avec le total additionné à partir de ces lignes.
  • Un ticket, c’est courriel plus objet.

Deux d’entre elles portent un avertissement dans le code. L’empreinte du service exclut délibérément la prochaine date d’échéance, car la cible refuse une date passée et en stocke une vide ; le même service aurait alors à la reprise une empreinte différente de celle qu’il avait à l’écriture et serait créé une seconde fois : un champ que la cible a le droit de modifier ne peut pas faire partie de la clé. Le total de la facture est additionné à partir des lignes plutôt que lu dans le champ total de la source, car sur la cible cette valeur est un calcul et non une colonne, et deux côtés qui additionnent différemment produisent une clé qui ne correspond jamais. Les lignes sont triées avant la composition, car l’ordre dans lequel elles reviennent est celui de leurs identifiants, qui n’est pas forcément l’ordre d’écriture si l’une d’elles est tombée.

La clé du ticket est la faible, et c’est écrit tel quel : deux tickets du même client avec exactement le même objet se confondent. Inclure le premier message le corrigerait et coûte un second appel par ticket sur la cible.

Trois façons de traiter une collision

Quand un objet existe déjà sur la cible, l’exécution fait une chose parmi trois, et le choix se fait à l’avance : skip laisse la ligne existante tranquille, overwrite_empty ne remplit que les champs qui y sont vides, et stop arrête toute l’exécution avant la moindre écriture.

Mesuré contre un vrai FOSSBilling 0.8 et un vrai Paymenter : une simulation a planifié quatre clients, quatre services, quatre factures, cinq produits et deux tickets ; l’exécution a créé quatre clients, quatre services, quatre factures et deux tickets, avec recomptage ensuite. Tuer le processus à mi-parcours avec kill -9 puis le relancer a créé zéro objet et laissé la cible identique octet pour octet. Avec stop, arrêt sans une seule écriture. Avec overwrite_empty, seuls les champs de nom vides ont été remplis, les autres laissés intacts. Une facture dans une devise non gérée est revenue en 422 nommant le champ, a été notée currency_unknown, et l’exécution a continué.

Pourquoi la route ne s’appelle pas live

L’outil est terminé et mesuré, et la route reste impossible à lancer depuis le panneau. La raison est une frontière qui s’est avérée importante : les identifiants d’une migration se lisent dans la table des intégrations, et la colonne de type de cette table est le registre de l’infrastructure que nous exploitons. Une entrée live y porte une page de documentation, un parcours de configuration, un schéma d’identifiants, un test de connexion et neuf traductions, et elle apparaît sur le site comme quelque chose avec quoi on pilote des serveurs. Un panneau de facturation n’est pas cela. Nous le lisons une fois et n’y retouchons jamais.

Appeler la route live poserait dessus un badge qui dit autre chose que l’écran en dessous, d’où un troisième statut, adapter_ready, qui dit ce qui est vrai : l’adaptateur existe et a été mesuré contre de vrais panneaux, et l’endroit où saisir les deux panneaux n’existe pas encore. Cet endroit est une manche à part entière, car enregistrer un panneau de facturation comme connexion est une décision de schéma et non un sous-produit d’un outil de migration.

Issu du travail de migration de Provibr. Pourquoi une API qui répond 200 en cas d’échec est une classe de bogue et non un accident se trouve dans pourquoi un 200 de Proxmox n’est pas un résultat, et pourquoi une migration qui a silencieusement sauté est pire qu’une qui a échoué se trouve dans la migration qui n’a jamais tourné.

Commencez avec un hôte et un agent

Créez un compte, installez l’agent avec une seule commande et connectez votre premier hôte Proxmox. La période d’essai dure 14 jours et ne demande aucune coordonnée de paiement.