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.

Le Purchase envoyé à tes régies (pixel et CAPI)

Une vente part vers Meta et TikTok par deux chemins : le pixel du navigateur, sur la page du checkout, et la CAPI, depuis le serveur. Les deux portent le même identifiant d'événement, le order_number (TR-000123) : la régie reconnaît la paire et compte la vente une fois. Le order_id des données de l'événement est aussi le order_number. Snapchat, Reddit et Pinterest (si leur jeton est réglé dans le Ttag) reçoivent la vente seulement par la CAPI : le navigateur ne leur envoie aucun Purchase, il n'y a pas de paire à reconnaître.

Moment Qui envoie le Purchase
COD, au placement navigateur + CAPI (sauf si la confirmation WhatsApp le retient : il part alors à la confirmation ou à l'expiration, CAPI seule)
Stripe / PayPal la CAPI au paiement confirmé (webhook) ; le navigateur au retour sur la landing, seulement si le montant a été mis de côté avant la redirection (et, pour PayPal, seulement si la commande n'a pas été annulée entre-temps)
Abonnement Stripe le premier paiement : comme Stripe ci-dessus, sur la commande d'origine (par prélèvement SEPA, Bacs ou ACH : la CAPI à l'arrivée du débit, des jours plus tard) ; chaque renouvellement : la CAPI, une fois, sur la commande fille de la facture
Mark as paid d'une COD rien si le Purchase est déjà parti ; sinon la CAPI, une fois

Un Purchase par paiement, jamais deux. Un webhook Stripe rejoué, un double clic sur Mark as paid, une facture d'abonnement livrée deux fois : aucun ne renvoie de Purchase. Sur une commande annulée, la CAPI n'envoie jamais rien.

Un paiement arrivé sur une commande annulée. Une commande Stripe ou PayPal restée non payée est annulée au bout d'une heure (sauf le premier débit différé d'un abonnement, paragraphe suivant), mais l'acheteur peut encore payer après (la page de paiement Stripe reste ouverte 24 h). Trackily ne passe pas la commande en payée : l'argent est chez la passerelle, à toi de rembourser ou de rouvrir. La CAPI n'envoie rien. Au retour PayPal, l'acheteur voit « paiement en vérification » et le navigateur n'envoie rien non plus. Au retour Stripe, en revanche, la page ne connaît pas l'état de la commande : si le montant avait été mis de côté, le pixel du navigateur envoie le Purchase, seul, sans jumeau CAPI. La régie compte alors une vente sur une commande annulée — un vrai encaissement, qu'il te reste à rembourser ou à rouvrir. Avant le 26 septembre 2026, le retour PayPal faisait de même.

Abonnement Stripe : un Purchase par paiement. Le premier paiement est porté par la commande d'origine : un Purchase. Chaque renouvellement crée une commande fille, valorisée au montant facturé par Stripe (sans port ni taxe, que la facture ne contient pas) : un Purchase par facture. Une facture à 0 (coupon à 100 %) ne crée pas de commande.

Payé par prélèvement (SEPA, Bacs, ACH) ou par virement, le premier débit arrive des jours après la fin du checkout. La commande d'origine reste « en attente » jusque-là, avec une autorisation Stripe en attente dans ses transactions : elle n'est ni annulée au bout d'une heure, ni relancée comme panier abandonné, pendant trois semaines au plus. À l'arrivée du débit, elle passe payée : un Purchase, un reçu, une conversion. Au-delà de trois semaines, elle est annulée comme les autres ; un débit arrivé ensuite est refusé et noté dans Logs, onglet System (« Paiement reçu sur la commande annulée », avec le numéro de la commande) : à toi de rembourser ou de rouvrir. Même chose pour une commande d'origine que tu as annulée toi-même. Si le débit arrive mais que Trackily n'a pas pu l'enregistrer (une erreur passagère de la base, une commande d'origine non reliée à l'abonnement, ou un abonnement que Trackily n'a pas pu enregistrer à sa création), Stripe ne le renverra pas : l'échec est écrit dans Logs, onglet System — cherche « Stripe non enregistré » ou le numéro de la commande (TR-…), que le message nomme —, et la commande reste en attente : marque-la payée à la main avant les trois semaines. L'abonnement que Trackily n'a pas pu enregistrer y est aussi, dès sa création (« Abonnement Stripe non enregistré », avec la commande). Si la base elle-même est injoignable, rien ne peut être écrit dans Logs : seuls la console du serveur et le tableau de bord Stripe gardent la trace. Avant le 27 septembre 2026, ces échecs n'étaient écrits que dans la console du serveur, et le paiement refusé sur une commande annulée était noté sous une catégorie qu'aucun onglet de Logs n'affichait. Le pixel du navigateur, lui, part au retour du checkout, avant le débit : si la CAPI suit plus de 48 h après, Meta peut compter deux ventes, et si le débit échoue, la vente du navigateur reste comptée.

Avant le 26 septembre 2026, le premier paiement par carte créait en plus une commande fille « cycle 1 » pour le même argent — deux ventes, deux Purchase, un revenu doublé —, un renouvellement pouvait créer deux commandes, et la commande fille valait le prix courant de la variante, port et taxe compris. Un premier débit par prélèvement était enregistré sur une commande fille « cycle 1 », la commande d'origine étant annulée au bout d'une heure, et l'acheteur pouvait recevoir la relance de panier abandonné. Les commandes passées ne sont pas corrigées rétroactivement.

Le retour de paiement sans montant ne tire rien. Si l'acheteur revient dans un autre navigateur (ou si l'URL ?_tly_paid=… est ouverte à la main), le pixel du navigateur se tait : il aurait envoyé un Purchase à 0. La CAPI envoie le vrai montant — règle un jeton CAPI sur ton pixel pour ne perdre aucune vente.

Avant le 26 septembre 2026, la CAPI envoyait l'identifiant interne de la commande au lieu du order_number : la paire n'était jamais reconnue, et une COD comptait deux ventes (encore une au « Mark as paid »). Les ventes passées ne sont pas corrigées rétroactivement.

L'outil MCP mark_native_order_paid passe la commande en payée sans envoyer de Purchase ni de reçu ; le bouton Mark as paid de l'admin, lui, le fait.

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