Orders
TL;DR : une commande native vit dans la table
orders. Cycle de vie :pending → paid → fulfilled → delivered. Origine : checkout sur landing (/api/order) ou page produit/p/<slug>. Outils MCPlist_native_orders,get_native_order_detail,mark_native_order_paid. Distinct des orders Shopify (autre table, autres outils — préfixésnative_).

Concept
orders est la table centrale du commerce native. Une ligne par achat. C'est elle qui matérialise l'attribution complète "click → cash" qui est la raison d'être de Trackily : landing_id, click_id, campaign_id, source_id sont stampés à la création de la commande, le ROAS et la marge nette se calculent en jointure directe sans intermédiaire.
Anatomie d'une commande (orders)
| Section | Champs | Rôle |
|---|---|---|
| Identité | id, order_number (TR-000001) |
clé interne + référence humaine |
| Client | customer_email, customer_name, customer_phone, shipping_address (JSONB) |
infos buyer |
| Totaux | currency, subtotal, shipping_cost, tax_amount, discount_amount, total |
breakdown financier |
| Paiement | payment_method, payment_account_id, payment_status, payment_reference |
qui paye, comment, où en est-on |
| Fulfillment | fulfillment_status, tracking_carrier, tracking_number |
logistique |
| Attribution | landing_id, click_id, campaign_id, source_id |
qui a converti |
| Shipping | shipping_zone_id, shipping_rate_name (snapshot) |
quelle zone, quel rate |
| Discount | discount_code (snapshot), discount_amount, discount_code_id |
code promo appliqué |
| Anti-spam | ip, user_agent |
audit |
| Cancel | cancelled_at |
si annulée |
| Timestamps | created_at, updated_at, paid_at, fulfilled_at |
audit complet |
Note : click_id est un TEXT (pas un UUID) parce qu'il pointe sur la table legacy clicks(id) dont la colonne est elle-même TEXT. Si tu trouves ça moche, c'est de l'histoire — voir le commentaire dans la migration v49.
Genèse de l'order_number
Le order_number n'est pas une colonne directement settable. Le helper db.createOrder fait :
INSERT INTO orders (…) VALUES (…) RETURNING id;
-- puis :
UPDATE orders SET order_number = 'TR-' || LPAD(id::text, 6, '0') WHERE id = $1;
Résultat : TR-000001, TR-000002, …, TR-099999. Au-delà de 99999 commandes, ça déborde sans casser (TR-100000). Joli pour les emails et les factures, pas exposé en URL.
payment_status : la state machine
┌──────────────────────┐
│ pending │ ← création initiale
└─────────┬────────────┘
│
▼
┌──────────────────────┐
│ paid │ ← webhook capture
└─────────┬────────────┘
│
┌────────┴────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ partial_refund │ │ refunded │ ← refund_native_order
└──────────────────┘ └──────────────────┘
(orthogonal : failed ← webhook capture failed
cancelled ← cancel_native_order setté `cancelled_at`)
| Status | Signification | Triggered by |
|---|---|---|
pending |
Commande créée, paiement pas encore confirmé | POST /api/order |
paid |
Argent reçu | webhook checkout.session.completed ou mark_native_order_paid |
refunded |
Remboursement total effectué | refund_native_order (amount = total) |
partial_refund |
Remboursement partiel | refund_native_order (amount < total) |
failed |
Paiement refusé (CB déclinée, etc.) | webhook payment_intent.payment_failed |
fulfillment_status : la state machine
unfulfilled → partial → fulfilled → shipped → delivered → returned
| Status | Signification |
|---|---|
unfulfilled |
Default — rien n'est parti |
partial |
Une partie du panier est expédiée (commande multi-line, expédition en plusieurs colis) |
fulfilled |
Marqué comme préparé (pas forcément expédié — utile pour les produits digitaux qui n'ont pas d'expédition physique) |
shipped |
Colis remis au transporteur, tracking attaché |
delivered |
Confirmation de livraison (manuelle ou via webhook transporteur) |
returned |
Retour reçu — étape pré-refund |
Ces deux statuts (payment_status et fulfillment_status) sont orthogonaux : une commande peut être paid + unfulfilled (paymement OK, pas encore préparé), pending + unfulfilled (en attente paiement), refunded + delivered (livré puis remboursé), etc. Voir fulfillment.md pour les transitions détaillées.
Origines d'une commande
Deux entry points :
1. Bloc checkout sur une landing
C'est le flow principal. Le visiteur arrive sur ta landing via /c/<slug>, voit le bloc checkout sous le formulaire, choisit sa variante, clique sur "Pay with Stripe". POST /api/order est appelé avec le landing_slug.
Trackily :
- Résout la landing → trouve
product_id,payment_account_ids,cod_enabled. - Valide les inputs (variante existe, stock OK, code promo valide, adresse présente si physical, etc.).
- Calcule subtotal, applique discount, calcule shipping via zones, calcule tax.
- INSERT
ordersenpayment_status='pending'. - INSERT les
order_items(snapshote product_name, variant_label, sku, unit_price). - Décrémente le stock atomiquement.
- Si Stripe : crée une Checkout Session, redirige le buyer.
- Si PayPal : crée une order PayPal, redirige.
- Si COD : passe direct sur une page de confirmation, statut reste
pendingjusqu'aumark_native_order_paidmanuel.
2. Page produit standalone /p/<slug>
Le visiteur arrive sur /p/memo-mind (sans landing intermédiaire). Le code construit en mémoire une "landing virtuelle" avec un landing_slug synthétique product-memo-mind. Le reste du flow est identique au point 1.
Différence : landing_id reste NULL sur la commande (parce qu'il n'y a pas de vraie landing). L'attribution se fait via click_id / campaign_id / source_id quand ils sont disponibles dans le cookie de session.
Comment faire (UI + MCP)
Via l'admin UI
- Commerce → Orders (ou
/admin#orders). - Tu vois la liste paginée, sortée par date desc.
- Filtres en haut : par statut paiement, statut fulfillment, méthode paiement, recherche libre (order_number, email), date range.
- Clique sur une ligne → page détail :
- Header avec order_number, statuts, total.
- Section client (email, nom, téléphone, adresse).
- Section panier (items avec image variant, prix, quantité).
- Section totaux décomposés.
- Section attribution (landing, campaign, source, click_id).
- Section transactions (ledger d'événements paiement).
- Boutons d'action : Mark as paid (COD), Fulfill (passer en shipped + tracking), Refund (partiel ou total), Cancel.

Via MCP
Lister les orders natives :
{
"name": "list_native_orders",
"arguments": {
"payment_status": "paid",
"fulfillment_status": "unfulfilled",
"payment_method": "stripe",
"from": "2026-05-01",
"to": "2026-05-18",
"limit": 50,
"offset": 0
}
}
Tous les filtres sont optionnels. Réponse type :
{
"status": "ok",
"tool": "list_native_orders",
"total": 142,
"count": 50,
"orders": [
{
"id": 87,
"order_number": "TR-000087",
"created_at": "2026-05-18T09:23:11Z",
"customer_email": "marie@example.com",
"customer_name": "Marie L.",
"currency": "EUR",
"subtotal": 49.90,
"shipping_cost": 5.00,
"tax_amount": 10.98,
"discount_amount": 10.00,
"total": 55.88,
"payment_method": "stripe",
"payment_status": "paid",
"fulfillment_status": "unfulfilled",
"cancelled_at": null,
"landing_id": 14,
"campaign_id": 7
}
]
}
Détail complet d'une commande :
{
"name": "get_native_order_detail",
"arguments": { "order_id": 87 }
}
Réponse inclut les order_items, l'adresse complète, les order_transactions, et les infos d'attribution.
Marquer comme payée (utile pour COD — la commande reste pending jusqu'à ce que tu reçoives le cash en personne) :
{
"name": "mark_native_order_paid",
"arguments": {
"order_id": 87,
"note": "Cash reçu en main propre le 18/05/26"
}
}
Réponse Tier-2 première étape :
{
"status": "confirmation_required",
"tool": "mark_native_order_paid",
"preview": {
"order_id": 87,
"order_number": "TR-000087",
"customer_email": "marie@example.com",
"total": 55.88,
"currency": "EUR",
"current_payment_status": "pending",
"new_payment_status": "paid",
"note": "Cash reçu en main propre le 18/05/26"
},
"confirm_token": "tk_…"
}
Re-soumets avec le confirm_token pour exécuter.
Annuler (et restocker) :
{
"name": "cancel_native_order",
"arguments": {
"order_id": 87,
"reason": "Client a changé d'avis sous 24h"
}
}
Cancel ne déclenche pas de refund automatique côté Stripe/PayPal. Si le paiement était déjà capturé, appelle
refund_native_orderséparément.
Recherche libre (par order_number, email) :
{
"name": "search_orders",
"arguments": {
"q": "TR-000087",
"platform": "native",
"limit": 10
}
}
search_ordersest un outil unifié qui cherche dans les ordres natives ET Shopify selonplatform("native" / "shopify" / "all"). Pour ne rester que sur le natif, passeplatform: "native".
Stats d'un produit
{
"name": "get_native_product_stats",
"arguments": {
"product_id": 12,
"window": "30",
"days": 30
}
}
Renvoie KPI summary (clicks, conversions, revenue, cost, CR, EPC, AOV, profit), daily breakdown, campagnes liées, landings liées (avec badges A/B price override), variant breakdown. Même donnée que la page admin "Native Product Stats".
Distinction native vs Shopify
Très important : les outils MCP commerce viennent en deux flavors parallèles :
| Native (cette section) | Shopify (autre section) |
|---|---|
list_native_orders |
list_orders |
get_native_order_detail |
get_order_detail |
mark_native_order_paid |
mark_order_as_paid |
cancel_native_order |
cancel_order |
refund_native_order |
refund_order |
fulfill_native_order |
fulfill_order |
auto_fulfill_ready_orders ❌ (pas pour native) |
auto_fulfill_ready_orders ✅ |
Pourquoi pas un set unifié ? Parce que les data sources et les pipelines de paiement / fulfillment sont radicalement différents :
- Native : table
orders, paiement via Stripe/PayPal en direct, fulfillment manuel. - Shopify : API Shopify, paiement géré par Shopify, fulfillment via les apps Shopify (parfois automatique).
Pour les outils unifiés (rapports, anomaly detection), c'est OK de mélanger — search_orders accepte platform="all". Mais pour toute mutation, identifie clairement si tu cibles le natif ou Shopify.
Exemples concrets
1. Workflow journalier : récupérer les orders à fulfiller
Ton routine matinale, via MCP :
{
"name": "list_native_orders",
"arguments": {
"payment_status": "paid",
"fulfillment_status": "unfulfilled",
"limit": 200
}
}
Pour chaque commande, tu prépares le colis, tu attaches le tracking via fulfill_native_order (voir fulfillment.md).
2. Reporting hebdomadaire
{
"name": "list_native_orders",
"arguments": {
"payment_status": "paid",
"from": "2026-05-11",
"to": "2026-05-18",
"limit": 200
}
}
Puis tu agrèges les total par currency pour avoir ton CA de la semaine. Croise avec campaign_id pour le ROAS par campagne.
3. Suivi d'une commande spécifique
Client appelle disant "ma commande TR-000087" :
{
"name": "search_orders",
"arguments": { "q": "TR-000087", "platform": "native", "limit": 1 }
}
Puis get_native_order_detail avec l'id remonté pour avoir le détail complet (items, statuts, tracking, transactions). Tu peux refunder, ré-envoyer le tracking, créer un store credit (code promo dédié).
4. Détecter les COD en attente
Les commandes COD restent en pending jusqu'à ce que tu marques paid à la livraison :
{
"name": "list_native_orders",
"arguments": {
"payment_status": "pending",
"payment_method": "cod",
"from": "2026-04-01",
"to": "2026-05-01",
"limit": 100
}
}
Les commandes > 30 jours en pending COD sont quasi sûrement des bad leads. Cancel-les pour libérer le stock.
5. Abandoned cart recovery
Trackily fournit un cron (commerce_default_abandoned_cart_list_id setting) qui détecte les commandes pending > threshold (par défaut 1h) avec payment_method='stripe' (le client a démarré le checkout Stripe mais n'a pas finalisé). Le cron enrole le buyer dans la séquence email "abandoned cart" pour la relance.
Le flag abandoned_cart_emailed_at empêche le double enrôlement.
Voir email/sequences.md pour le setup.
Erreurs courantes
- "L'order reste
pendingalors que Stripe a encaissé" — le webhookcheckout.session.completedn'a pas tapé. Vérifie l'URL de webhook côté Stripe (/webhook/stripe/native-pending), lewebhook_secretcôté Trackily, et le statut "succeeded" du webhook dans Stripe → Developers → Webhooks → cliquer sur l'endpoint → logs. - "Le client dit qu'il a payé mais je ne vois pas la commande" — la commande a peut-être été créée en
failed(carte refusée puis revalidée par une autre voie). Cherche par email avecpayment_statusnon filtré. - "Stock décrémenté mais commande failed" — c'est un bug. Le décrément est dans la même transaction que l'INSERT, donc rollback ensemble. Si tu vois ça, dump le trace dans GitHub Issues.
- "
mark_native_order_paidme ditnoop: already paid" — comportement attendu : si la commande est déjàpaid, le tool ne fait rien et te renvoiestatus=noop. Pas une erreur. - "Mon order_number a un trou (TR-000054 puis TR-000056)" — quelqu'un a créé une commande qui a été rollback ensuite. SERIAL ne réutilise pas les IDs morts. Ne t'en fais pas, c'est l'ordre naturel.
- "Les commandes Shopify n'apparaissent pas dans
list_native_orders" — c'est attendu, voir distinction ci-dessus. Utiliselist_orderspour Shopify ousearch_ordersavecplatform: "all". - "L'attribution
campaign_idest NULL" — le visiteur est arrivé sans cookie click (deeplink, partage social, etc.). Vérifie aussi que ta campagne pousse bien via/c/<slug>(qui stamp le cookie), pas en lien direct. - "Refunder une commande pending" — refusé, le refund n'a de sens que sur un paiement capturé. Cancel-la à la place.
Performance
Index présents pour les requêtes courantes :
idx_orders_payment_status (payment_status)
idx_orders_fulfillment_status (fulfillment_status)
idx_orders_created (created_at DESC)
idx_orders_customer_email (customer_email)
idx_orders_landing (landing_id)
idx_orders_click (click_id)
idx_orders_campaign (campaign_id)
idx_orders_payment_ref (payment_reference) WHERE payment_reference <> ''
Si tu interroges souvent sur une combinaison non couverte (par exemple customer_email + payment_status), ouvre un issue pour ajouter un index composite.
Voir aussi
- fulfillment.md — marquer une commande fulfilled, attacher tracking.
- refunds.md — refunder partiellement ou totalement.
- products.md —
native_productsqui alimenteorder_items. - variants.md — la matrice variante / stock.
- shipping.md — zones et rates qui alimentent
shipping_cost. - discounts.md — codes promo qui alimentent
discount_amount. - payment-accounts.md — comptes paiement qui alimentent
payment_account_id. - Email → Sequences — séquences post-purchase et abandoned cart.
- Automizer → Rules — règles auto sur les orders (auto-fulfill, anomaly).