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 (bouton de l'admin, ou l'agent avec mark_native_order_paid) 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 a les mêmes effets que le bouton Mark as paid de l'admin : la conversion, le Purchase (une fois, avec les mêmes règles que ci-dessus), le reçu à l'acheteur, le message post-achat et la notification aux administrateurs. Son aperçu les liste, pour la commande en question, avant que tu confirmes, et son résultat ne nomme comme déclenchés que ceux qui partent (une commande d'un funnel qui n'est pas live, par exemple, n'en déclenche aucun) ; sur un serveur MCP monté sans ces effets, il refuse au lieu de marquer payée en silence. Comme le bouton, il refuse une commande annulée ou remboursée. Avant le 2 octobre 2026, il passait la commande en payée sans rien envoyer : ni Purchase, ni reçu, ni message post-achat.

La référence de paiement reste sur la commande. Un Mark as paid — le bouton, la sélection en masse ou l'agent — garde la référence de la passerelle déjà posée sur la commande. Une commande en attente porte une référence provisoire, la session de paiement Stripe (cs_…) ou la commande PayPal : au remboursement depuis Trackily (bouton, sélection en masse ou agent), la session est relue chez Stripe et son payment_intent est remboursé, la commande PayPal est relue chez PayPal et sa capture est remboursée. La référence trouvée est posée sur la commande : c'est par elle que les webhooks de remboursement la retrouvent. Si Stripe ou PayPal confirme le paiement après ton marquage (webhook tardif), la référence provisoire est remplacée aussitôt. Une session jamais payée chez Stripe, ou une commande PayPal sans capture terminée, veut dire que la commande a été payée autrement : le remboursement est refusé avec la raison, rembourse par le même moyen. Une commande sans référence (une COD, un virement) reçoit la référence que tu saisis, sinon manual:<ton e-mail> (bulk-manual:<ton e-mail> en masse, mcp:<id du jeton> pour l'agent), que le reçu PDF imprime. La ligne de transaction porte toujours cette référence manuelle : c'est la trace de qui a marqué la commande. Avant le 4 octobre 2026, le bouton et la sélection en masse remplaçaient la référence Stripe ou PayPal par manual:admin ou bulk-manual:<e-mail> : le remboursement automatique échouait, et un remboursement fait depuis Stripe ou PayPal ne passait pas la commande en refunded ; une telle commande porte encore manual:… : rembourse-la depuis Stripe ou PayPal. Avant la relecture du même jour, la session ou la commande PayPal gardée partait telle quelle au remboursement, que la passerelle refusait.

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",
    "payment_reference": "mcp:12",
    "note": "Cash reçu en main propre le 18/05/26",
    "consequence": "Marquer payée déclenche la conversion, le Purchase (CAPI des régies, une fois par commande), le reçu e-mail, le message post-achat (e-mail et WhatsApp) et la notification aux administrateurs — exactement comme le bouton « Mark as paid » de l’admin. Ce qui partira pour CETTE commande, et ce qui ne partira pas, est sous `effets`.",
    "effets": {
      "conversion": "enregistrée (idempotente par order_number : une COD l’est déjà depuis son placement), postback à la source compris",
      "purchase": "déjà parti au placement de la COD ou à sa confirmation WhatsApp — non réémis (§30.13)",
      "recu": "e-mail de confirmation avec reçu PDF à marie@example.com, si un serveur SMTP est actif",
      "post_achat": "inscription à la liste e-mail post-achat (si elle est réglée) et message WhatsApp post-achat (selon ses réglages ; une COD n’a pas de modèle post-achat)",
      "notification_admin": "notification « New paid order » à chaque administrateur"
    }
  },
  "confirm_token": "tk_…"
}

Re-soumets avec le confirm_token pour exécuter : la commande passe payée et les effets listés sous effets partent, comme depuis le bouton Mark as paid. Le message du résultat ne dit « Déclenchés » que de ce qui part ; ici : Commande TR-000087 marquée payée. Déclenchés, comme le bouton « Mark as paid » de l’admin : conversion, reçu, message post-achat, notification aux administrateurs. Ne partent pas : Purchase. Le détail de chaque effet est sous effets. La transaction porte provider: "manual", la référence mcp:<id du jeton> (la trace du geste) et ta note ; la commande garde sa référence Stripe ou PayPal si elle en a une, sinon reçoit mcp:<id du jeton> — la règle du bouton (voir « La référence de paiement reste sur la commande »). Rejouer le même jeton après succès rend noop (déjà payée), sans rien renvoyer. Une commande annulée est refusée avant tout jeton (code: "commande_annulee"), une commande remboursée aussi (refunded ou partial_refund, code: "commande_remboursee").

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.

Marquer payée une commande Shopify : les effets du webhook

Depuis le 2026-10-04, mark_order_as_paid déclenche côté Trackily ce que déclenche le webhook Shopify orders/paid (POST /webhook/shopify), sans l'attendre. Après la transaction créée chez Shopify, l'outil relit la commande et, si elle est payée, appelle le même traitement que le webhook et la synchronisation (lib/shopify-commande-payee.js) : la ligne shopify_order du journal des événements, puis, si la commande porte un clic Trackily, la conversion (approved, revenu = total), la notification « conversion » et le postback à la source du clic. AVANT, l'outil s'arrêtait à la transaction Shopify : rien côté Trackily tant que le webhook ou la synchronisation ne passaient pas.

  • Dédoublonné par commande : le webhook ou la synchronisation qui arrivent ensuite n'ajoutent rien (ni conversion, ni notification, ni ligne de journal). Chaque boutique Shopify numérote depuis #1001 : une commande dont le numéro porte déjà la conversion d'un autre clic (une autre boutique) reçoit la clé <numéro>@<id Shopify> et sa propre ligne de journal — l'aperçu le dit.
  • Aucun clic Trackily retrouvé sur la commande : seule la ligne de journal, exactement comme le webhook. Une boutique sans suivi n'est pas forcément « sans clic » : un ?ref= générique (ref=instagram) n'attribue plus la commande — seul un ref en forme d'id de clic Trackily est lu —, et un clic retrouvé par sub1/sub2 (la valeur d'une source) est signalé dans l'aperçu : vérifie que c'est bien l'acheteur.
  • Le revenu est converti en USD au taux du jour (table exchange_rates) — l'aperçu dit « 149 MAD ≈ 14,9 USD au taux du jour » ; le montant d'origine, sa devise et le taux restent dans custom_data. Sans taux connu pour la devise, il est compté tel quel, devise non convertie, et l'aperçu le dit (AVANT le 2026-10-04 : jamais converti).
  • Une conversion pending laissée par orders/create (commande pas encore payée : ni notification ni postback) passe en approved au marquage : la notification et le postback partent à cette bascule, une fois — l'aperçu l'annonce.
  • Jamais de Purchase/CAPI ni de commande locale : le chemin Shopify n'en fait pas (c'est la différence avec mark_native_order_paid).
  • L'aperçu dit, sous effets_trackily, ce qui partira pour cette commande ; le résultat, ce qui est parti — ou pourquoi rien n'est parti (relecture Shopify en échec, commande pas paid) et qui peut encore rattraper les effets (effets_trackily.rattrapage : un webhook réglé, une synchronisation qui relira vraiment la commande), ou, si le traitement a cassé en route, ce qui est déjà écrit (declenches: "partiel").
  • Déjà payée : no-op, ni transaction ni effet. Pour lancer quand même ses effets Trackily (pas de webhook, synchronisation qui ne la couvre pas, relecture en échec), rappelle avec replay_trackily_effects: true : aperçu puis jeton, jamais de transaction Shopify, dédoublonné. Sur une commande pas encore payée, ce paramètre est refusé (code: "pas_encore_payee").

Contrat : docs/whatsapp/CONCEPTION.md §33.4.

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