4 min de lecture
Réconcilier des commandes à 2 h du matin
Keforo App rassemble les commandes de Shopify, WooCommerce et Google Sheets en un seul endroit. Le plus dur n'a jamais été les API. C'était de s'accorder sur ce qu'est une commande quand trois systèmes ne sont pas d'accord.
Keforo App est né d'une petite promesse : chaque commande qu'une boutique reçoit, de chaque canal où elle vend, dans une seule liste, juste. Shopify, WooCommerce et la feuille Google que l'équipe utilise quand un client écrit sur WhatsApp. Trois sources, une vérité.
Les API ont pris une semaine. S'accorder sur ce qu'est une commande a pris le reste de l'année.
#Trois sources, trois horloges
Shopify envoie un webhook à l'instant où quelque chose change et vous laisse tout re-récupérer. WooCommerce envoie aussi des webhooks, quand le plugin en a envie, et ses horodatages sont dans le fuseau de la boutique. La feuille n'a ni webhook, ni identifiant au-delà d'un numéro de ligne, et des humains la modifient directement, parfois rétroactivement.
D'où la première règle : ne jamais croire qu'un événement raconte toute l'histoire. Chaque webhook est un indice qui dit « re-récupère cette commande ». Chaque exécution planifiée relit tout ce qui a changé depuis un repère, par source, dans l'horloge de cette source.
#Une commande canonique
Chaque source est normalisée dans la même forme avant que quoi que ce soit d'autre y touche.
interface CanonicalOrder {
source: 'shopify' | 'woocommerce' | 'sheet'
externalId: string // Shopify id, Woo id, or "sheet:<rowKey>"
revision: string // updated_at, or a hash of the row
fingerprint: string // sha256 of the normalised payload
customer: { email: string | null; phone: string | null; name: string }
lines: { sku: string; quantity: number; unitPrice: number }[]
total: number
currency: string
status: 'open' | 'paid' | 'shipped' | 'cancelled'
placedAt: string // ISO, UTC
notes: string[]
}revision est ce que la source dit avoir changé. fingerprint est ce qui a réellement changé. La différence compte : WooCommerce incrémente updated_at quand quelqu'un ouvre la commande dans l'admin sans y toucher.
#Une ingestion idempotente
Les écritures sont des upserts indexés par (source, externalId). Si l'empreinte correspond à ce qui est stocké, l'écriture est ignorée. N'importe quel worker peut traiter n'importe quel job, dans n'importe quel ordre, plus d'une fois, et la base finit dans le même état.
await Orders.updateOne(
{ source: order.source, externalId: order.externalId },
{
$setOnInsert: { firstSeenAt: new Date() },
$set: { ...order, syncedAt: new Date() },
},
{ upsert: true },
)Ignorer les empreintes inchangées est ce qui rend la relecture complète assez bon marché pour la faire toutes les quelques minutes.
#Quand les sources ne sont pas d'accord
La même commande réelle peut exister dans deux sources : une commande Shopify que l'équipe a aussi saisie dans la feuille pour ajouter une note de livraison. Les apparier est heuristique (même e-mail ou téléphone, même total, dans une fenêtre de temps), et apparier n'est pas fusionner.
« Le dernier qui écrit gagne » est faux quand il s'agit d'argent. Les règles sont par champ.
| Champ | Qui gagne | Pourquoi |
|---|---|---|
status, total, lines | le canal qui a encaissé le paiement | c'est le système de référence pour la vente |
customer.phone | toute source qui en a un | les canaux l'omettent souvent ; la feuille l'a |
notes | fusionnées, dédoublonnées | les humains ajoutent du contexte, personne n'en retire |
placedAt | la plus ancienne | la feuille est saisie après coup |
Tout ce que les règles ne peuvent pas trancher devient un conflit : stocké, affiché, et laissé à un humain. Une exécution typique sur une boutique active prend environ une seconde et demie et signale deux ou trois conflits. Aucun n'est résolu automatiquement. Ce nombre est resté bas précisément parce que les règles refusent de deviner.
#Files d'attente, backoff, et la partie « 2 h du matin »
Chaque source a sa propre file sur Redis. Un job est une commande, pas une source, donc un webhook Woo cassé ne bloque pas Shopify. Les nouvelles tentatives utilisent un backoff exponentiel avec gigue et une file de rebuts après cinq essais ; ces rebuts sont ce que je lis le matin.
Le titre de cette note n'est pas une métaphore. La première version réconciliait tout dans un seul job nocturne, et quand il échouait à 2 h du matin, il échouait d'un bloc. Des jobs par commande et des écritures idempotentes ont rendu les échecs petits, reprenables et ennuyeux. C'est toute l'architecture : rendre chaque étape sûre à répéter, et répéter librement.
#Ce que je ferais autrement
L'empreinte dès le premier jour ; je l'ai ajoutée après le deuxième incident de notifications en double. Stocker la charge brute à côté de la commande canonique, parce que chaque question « pourquoi ça dit 43 au lieu de 42 » finit dans les données brutes. Et traiter la feuille comme une source de premier rang dès le départ, au lieu de l'arrière-pensée qu'elle a été pendant deux mois.
Ça vous a parlé ?
Conversation
Vous construisez quelque chose de ce genre ?
Dites-moi sur quoi vous travaillez. Je réponds sous un jour.