Trackily Docs

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 MCP list_native_orders, get_native_order_detail, mark_native_order_paid. Distinct des orders Shopify (autre table, autres outils — préfixés native_).

Liste des orders

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 :

  1. Résout la landing → trouve product_id, payment_account_ids, cod_enabled.
  2. Valide les inputs (variante existe, stock OK, code promo valide, adresse présente si physical, etc.).
  3. Calcule subtotal, applique discount, calcule shipping via zones, calcule tax.
  4. INSERT orders en payment_status='pending'.
  5. INSERT les order_items (snapshote product_name, variant_label, sku, unit_price).
  6. Décrémente le stock atomiquement.
  7. Si Stripe : crée une Checkout Session, redirige le buyer.
  8. Si PayPal : crée une order PayPal, redirige.
  9. Si COD : passe direct sur une page de confirmation, statut reste pending jusqu'au mark_native_order_paid manuel.

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

  1. Commerce → Orders (ou /admin#orders).
  2. Tu vois la liste paginée, sortée par date desc.
  3. Filtres en haut : par statut paiement, statut fulfillment, méthode paiement, recherche libre (order_number, email), date range.
  4. 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.

Order detail

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_order séparément.

Recherche libre (par order_number, email) :

{
  "name": "search_orders",
  "arguments": {
    "q": "TR-000087",
    "platform": "native",
    "limit": 10
  }
}

search_orders est un outil unifié qui cherche dans les ordres natives ET Shopify selon platform ("native" / "shopify" / "all"). Pour ne rester que sur le natif, passe platform: "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 pending alors que Stripe a encaissé" — le webhook checkout.session.completed n'a pas tapé. Vérifie l'URL de webhook côté Stripe (/webhook/stripe/native-pending), le webhook_secret cô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 avec payment_status non 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_paid me dit noop: already paid" — comportement attendu : si la commande est déjà paid, le tool ne fait rien et te renvoie status=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. Utilise list_orders pour Shopify ou search_orders avec platform: "all".
  • "L'attribution campaign_id est 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