Ingénierie
Pourquoi nous ne calculons jamais un total à partir des lignes affichées
Par Jahangir Alam · 16 septembre 2026 · 6 minutes de lecture
Si votre interface peut masquer une ligne, vos totaux ne peuvent pas être dérivés des lignes qu'elle affiche. Écrit ainsi, cela paraît évident. Cela ne l'est pas au moment où l'on écrit le composant, et c'est la racine de toute une famille de bugs où un système laisse fuir exactement le chiffre qu'on lui demandait de cacher.
Cet article parle de deux fonctionnalités qui semblent sans rapport jusqu'à ce qu'elles se rencontrent, de l'invariant qui les garde honnêtes, et de l'endroit où nous nous sommes quand même trompés.
Le décor
Nous construisons une application de devis B2B pour Shopify. Deux capacités interagissent d'une façon qu'aucune des deux n'anticipe :
- La visibilité du prix par ligne. Un marchand peut marquer une ligne comme prix masqué : l'acheteur voit l'article avec « Inclus » à la place du chiffre. Un transport absorbé, un échantillon, une prestation intégrée à une autre ligne.
- L'acceptation partielle. Un acheteur peut accepter certaines lignes d'une proposition, en refuser d'autres et laisser le reste ouvert.
Les deux changent quelles lignes l'acheteur voit. Aucune n'a le droit de changer ce que l'acheteur doit.
Le bug que vous allez écrire
L'implémentation naturelle d'un bloc de totaux est une réduction sur les lignes que vous affichez :
const subtotal = visibleLines.reduce((acc, l) => acc + l.lineTotal, 0)
Masquez maintenant une ligne à 500 $. Le sous-total baisse silencieusement de 500 $, et l'acheteur voit un total qui ne correspond pas au devis qu'on lui a envoyé. Vous corrigez donc de la façon évidente : sommer toutes les lignes, n'en afficher que certaines.
const subtotal = allLines.reduce((acc, l) => acc + l.lineTotal, 0) // toujours faux
Le total est désormais juste et la ligne est masquée. Et l'acheteur peut retrouver le prix caché par une seule soustraction, parce que l'écart entre les lignes visibles et le total imprimé est le chiffre caché. Vous avez construit une énigme, pas une fonctionnalité de confidentialité.
La règle
Les totaux présentés à l'acheteur viennent du total enregistré du devis. Jamais des lignes affichées.
Le chiffre faisant foi est écrit au moment où la proposition est envoyée. Chaque surface - la boutique, l'extension du compte client, le portail hébergé, quatre modèles de PDF - lit cette valeur enregistrée. Aucune ne la recalcule.
C'est nécessaire mais pas suffisant, car seul, cela ne fait que déplacer la fuite. Si le total enregistré inclut le prix d'une ligne masquée, la soustraction fonctionne toujours. La règle a donc besoin d'une contrainte jumelle :
Une ligne à prix masqué doit être à zéro, imposé avant l'envoi.
Ce qui est de toute façon la version honnête de la fonctionnalité. « Prix masqué » ne peut vouloir dire que cela ne vous coûte rien de plus. Cela ne peut pas vouloir dire nous ne vous dirons pas ce que cela coûte, car une ligne qui déplace un total visible n'est pas masquée : elle est brouillée, et les acheteurs savent compter. Nous refusons la proposition à l'envoi plutôt que d'afficher quelque chose dont il faudrait ensuite s'excuser.
Ensemble, les deux contraintes donnent une propriété qui mérite d'être posée comme invariant :
Les lignes visibles font toujours le total imprimé. Pas approximativement. Exactement.
Puis l'acceptation partielle arrive et casse tout de même l'invariant
Voici ce que nous avons raté, et c'est la moitié intéressante.
Quand un acheteur accepte une partie d'un devis, la confirmation doit montrer les lignes acceptées. Nous avons cadré le tableau des lignes, ajouté un montant de total accepté, écrit les tests, livré.
Le document est sorti avec un tableau de 1 400 $ de lignes acceptées, sous un en-tête annonçant TOTAL CONFIRMÉ 1 825,00 $.
Le bloc de totaux avait été mis à jour. L'en-tête - un composant distinct, affiché au-dessus, écrit à une époque où toute acceptation était globale - non. Il lisait toujours le total du devis entier, correctement de son propre point de vue, et contredisait le tableau situé huit centimètres plus bas.
L'invariant a tenu là où nous pensions, et cassé là où nous ne pensions pas. Un second lecteur du même fait sous-jacent, écrit des mois plus tôt, est devenu obsolète en silence.
Le correctif qui généralise
Pas « mettre aussi l'en-tête à jour ». Une fonction par laquelle tout consommateur du chiffre de tête doit passer :
export function documentHeadlineTotal(quote: QuoteContext): {
label: "Accepted total" | "Confirmed total"
amount: string
} {
return quote.acceptedTotals
? { label: "Accepted total", amount: quote.acceptedTotals.subtotal }
: { label: "Confirmed total", amount: quote.version.totalEstimate }
}
Le libellé et le montant sont choisis ensemble et renvoyés ensemble. Vous ne pouvez pas prendre le montant accepté et garder le libellé du devis entier, puisqu'ils arrivent comme une seule valeur. La classe de bug est éliminée par conception, pas rustinée.
Si vous ne retenez qu'une chose : quand deux éléments doivent concorder, renvoyez-les depuis la même fonction. Deux points d'appel qui lisent la même source divergeront dès que l'un des deux sera modifié.
Ce que nous dirions à une autre équipe
- Trouvez tous les consommateurs d'un chiffre dérivé avant de changer ce qu'il signifie. Nous avons cherché dans le composant de totaux et pas dans l'en-tête du document. Un inventaire « qui affiche un total ? » l'aurait trouvé en cinq minutes.
- Imposez à la frontière, pas au rendu. La contrainte de prix nul est vérifiée à l'envoi. Au moment du rendu, il ne reste qu'à mentir ou à planter.
- Testez l'arithmétique, pas la présence. Affirmer que « le total accepté est affiché » passe joyeusement pendant que l'en-tête le contredit. Vérifiez que les lignes visibles font le chiffre imprimé.
- Rasterisez vos PDF dans les tests. L'extraction de texte nous disait que les bonnes chaînes étaient présentes. Elles l'étaient - au mauvais endroit, disant la mauvaise chose en combinaison. Seule une image l'a montré.
Nous avons vérifié le comportement final contre un vrai devis : quatre lignes à 1400 + 350 + 75 + 0 (cette dernière à prix masqué), un total enregistré de 1825, un acheteur qui a accepté deux lignes, et une confirmation titrée Total accepté 1 400,00 au-dessus d'un tableau de ces deux lignes exactement. Les lignes visibles font le total imprimé. C'est tout le test.
Note pour les développeurs d'applications Shopify
Si vous construisez sur la surface d'extension du compte client, il y a un piège voisin. La limite de 64 Ko du bundle pousse à charger les chaînes de texte au moment de l'exécution plutôt qu'à les embarquer, ce qui est raisonnable. Mais alors une clé peut légitimement manquer - et strings.someLabel.replace("{count}", n) lève une TypeError, qui fait planter l'extension, ce qui laisse l'indicateur de chargement de l'hôte tourner indéfiniment sans qu'aucune erreur ne soit montrée à l'acheteur. Interpolez via une fonction utilitaire tolérante au null. Demandez-nous comment nous le savons.
Où cela se voit dans le produit
Les deux fonctionnalités décrites ici sont en production. La visibilité du prix par ligne et l'acceptation partielle sont disponibles à partir du forfait Professional - voir la négociation et les propositions pour la version côté marchand des mêmes mécanismes, l'acceptation partielle pour ce que cela signifie commercialement, et les tarifs pour savoir quel forfait porte quoi.
Questions fréquentes
Pourquoi ne faut-il pas calculer un total à partir des lignes affichées ?
Parce que toute interface capable de masquer une ligne produit alors deux chiffres qui ne concordent pas. Si vous ne sommez que les lignes visibles, le total baisse quand une ligne est masquée et contredit le devis envoyé. Si vous sommez toutes les lignes et n'en affichez que certaines, l'écart entre les lignes visibles et le total imprimé est exactement le prix caché.
Pourquoi une ligne à prix masqué doit-elle être à zéro ?
Parce que sinon elle n'est pas masquée du tout. Si une ligne masquée déplace le total visible, son prix se retrouve par soustraction. La contrainte de zéro est ce qui fait du masquage une affirmation honnête : « Inclus » veut dire que la ligne ne coûte rien de plus à l'acheteur.
Articles liés
- IngénierieNous avons demandé à Shopify un nouveau type d'intention Sidekick. Ils l'ont livré.9 minutes de lecture
- IngénierieConstruire une intégration Shopify Flow : déclencheurs, actions et les pièges qui nous ont coûté un cycle11 minutes de lecture
- IngénierieConstruire une extension d'application Shopify Sidekick : ce que la doc sous-estime10 minutes de lecture
Découvrez comment QuotWay gère cela sur votre boutique.