---
title: "Réconcilier des commandes à 2 h du matin"
description: "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."
date: 2026-06-28
tags: ["keforo", "files d'attente", "backend"]
language: fr
canonical: https://aissamirhir.com/fr/blog/reconciling-orders-at-2am
source: aissamirhir.com
---
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.

```typescript title="order.ts"
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.

```typescript
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.

> [!NOTE]
> La feuille est aussi une source de vérité. Il est tentant de la traiter comme une copie dégradée, mais c'est le seul endroit où l'équipe écrit ce qu'un client a dit au téléphone. Les modifications humaines sont des révisions comme les autres, et elles reçoivent le même traitement par empreinte.

## 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.
