Shipping
TL;DR : tu déclares des zones (groupes de country codes) et N rates par zone (Standard, Express, Free over 50). Une seule zone
is_rest_of_world=true(catch-all) est autorisée. Côté checkout, le code prend le pays du visiteur, trouve la première zone qui match, présente ses rates au visiteur.

Concept
Trackily Commerce native suit le modèle Shopify pour la livraison :
- Tu regroupes des pays dans des zones (
shipping_zones) — par exemple "European Union" = FR, DE, IT, ES, BE, NL. - Pour chaque zone, tu définis un ou plusieurs rates (
shipping_rates) — par exemple "Standard 5 €" + "Express 15 €". - Au checkout, Trackily lit le pays du visiteur (depuis l'adresse qu'il a saisie), match la première zone qui contient ce code, présente la liste des rates de cette zone.
Le visiteur choisit. Trackily snapshote le shipping_rate_name et le prix dans la commande, pour que la commande reste lisible même si tu changes ou supprimes la rate après coup.
Anatomie d'une zone (shipping_zones)
| Champ | Type | Rôle |
|---|---|---|
id |
SERIAL | identifiant interne |
name |
TEXT | nom affiché ("European Union", "US & Canada") |
country_codes |
JSONB | tableau de codes ISO-3166 alpha-2 (["FR", "DE", "IT"]) |
is_rest_of_world |
BOOLEAN | si true, catch-all (et country_codes doit être vide) |
is_active |
BOOLEAN | si false, ignorée par le checkout |
position |
SMALLINT | ordre d'évaluation (la première qui match gagne) |
Index unique partiel : une seule zone peut avoir is_rest_of_world=true :
CREATE UNIQUE INDEX idx_shipping_zones_row_unique
ON shipping_zones(is_rest_of_world)
WHERE is_rest_of_world = true;
Tenter d'en créer une deuxième renvoie l'erreur "A rest-of-world zone already exists".
Anatomie d'une rate (shipping_rates)
| Champ | Type | Rôle |
|---|---|---|
id |
SERIAL | identifiant interne |
zone_id |
INTEGER FK | zone parente |
name |
TEXT | nom affiché au visiteur ("Standard", "Express", "Free over 50") |
price |
NUMERIC(12,2) | tarif fixe |
currency |
TEXT | ISO-3 ou chaîne vide (vide = s'applique à toutes les devises) |
min_order_amount |
NUMERIC(12,2) | optionnel — rate masquée si subtotal < seuil |
max_order_amount |
NUMERIC(12,2) | optionnel — rate masquée si subtotal > seuil |
is_active |
BOOLEAN | flag d'activation |
position |
SMALLINT | ordre d'affichage au checkout |
Fallback : native_products.shipping_cost
Si aucune zone ne match (parce que le pays n'est dans aucune zone et qu'il n'y a pas de zone rest-of-world), Trackily retombe sur la shipping_cost flat du produit (native_products.shipping_cost). C'est le mode "simple" pour les opérateurs qui ne veulent pas se prendre la tête avec les zones — toutes les commandes payent le même shipping.
L'ordre de résolution :
1. Le visiteur a saisi un country_code
2. SELECT * FROM shipping_zones
WHERE is_active=true
AND ( $country = ANY(country_codes) OR is_rest_of_world = true )
ORDER BY is_rest_of_world ASC, position ASC, id ASC LIMIT 1
3. Si une zone est trouvée → lister ses shipping_rates actives filtrées
par currency + min/max_order_amount → laisser le visiteur choisir
4. Sinon → fallback sur native_products.shipping_cost (et fallback
sur free_shipping_above si le subtotal dépasse le seuil)
Snapshot dans la commande
À la création de l'order, on persiste dans la ligne orders :
shipping_cost(NUMERIC) — le montant finalshipping_rate_name(TEXT) — le nom de la rate, dupliqué pour ne pas dépendre de la table sourceshipping_zone_id(INTEGER FK, SET NULL) — pour les rapports
Tu peux donc supprimer une rate ou rebaptiser une zone sans casser les commandes historiques.
Comment faire (UI + MCP)
Via l'admin UI
- Settings → Shipping zones (ou
/admin#shipping-zones). - + Add zone pour créer une nouvelle zone.
- Remplis :
- Name ("European Union").
- Countries (tape les codes ou sélectionne dans le picker).
- Rest of world — coche si c'est ta zone catch-all (et laisse Countries vide).
- Save. La zone apparaît dans la liste.
- Clique sur la zone → tab "Rates" → + Add rate.
- Pour chaque rate, remplis :
- Name ("Standard", "Express", "Free over 50 €").
- Price (5.00).
- Currency (EUR — ou vide pour s'appliquer à toutes les devises).
- Min order / Max order (optionnels).
- Position (ordre d'affichage).
- Save.
Via MCP
Lister :
{
"name": "list_shipping_zones",
"arguments": {}
}
Récupérer une zone avec ses rates :
{
"name": "get_shipping_zone",
"arguments": { "id": 3 }
}
Créer une zone avec ses rates inline (un seul appel — c'est plus rapide que zone + 2 rates en 3 appels) :
{
"name": "create_shipping_zone",
"arguments": {
"name": "European Union",
"country_codes": ["FR", "DE", "IT", "ES", "BE", "NL", "LU", "PT", "AT", "IE", "FI"],
"is_rest_of_world": false,
"is_active": true,
"rates": [
{ "name": "Standard", "price": 5.00, "currency": "EUR" },
{ "name": "Express", "price": 15.00, "currency": "EUR" },
{ "name": "Free over 75 €", "price": 0.00, "currency": "EUR", "min_order_amount": 75.00 }
]
}
}
Tier-2 : première réponse
confirmation_requiredavec preview +confirm_token. Tu re-soumets avec le token pour exécuter.
Update :
{
"name": "update_shipping_zone",
"arguments": {
"id": 3,
"name": "Union européenne",
"country_codes": ["FR", "DE", "IT", "ES", "BE", "NL", "LU", "PT", "AT", "IE", "FI", "SK"],
"is_active": true
}
}
Delete (cascade les rates) :
{
"name": "delete_shipping_zone",
"arguments": { "id": 3 }
}
Ajouter une rate à une zone existante :
{
"name": "create_shipping_rate",
"arguments": {
"zone_id": 3,
"name": "Express 24h",
"price": 19.90,
"currency": "EUR",
"min_order_amount": 0,
"position": 2
}
}
Update / delete d'une rate :
{
"name": "update_shipping_rate",
"arguments": { "id": 14, "price": 6.00 }
}
{
"name": "delete_shipping_rate",
"arguments": { "id": 14 }
}
Exemple réel : 3 zones (EU, US, ROW)
C'est la config minimale que je recommande pour un opérateur qui vend à l'international depuis l'Europe.
Zone 1 : European Union — tarifs en EUR
{
"name": "create_shipping_zone",
"arguments": {
"name": "European Union",
"country_codes": ["FR", "DE", "IT", "ES", "BE", "NL", "LU", "PT", "AT", "IE", "FI", "SK", "SI", "GR", "DK", "SE", "CZ", "PL", "HU", "RO", "BG", "HR", "CY", "MT", "LT", "LV", "EE"],
"position": 0,
"rates": [
{ "name": "Standard", "price": 5.00, "currency": "EUR", "position": 0 },
{ "name": "Express", "price": 15.00, "currency": "EUR", "position": 1 },
{ "name": "Free over 75 €", "price": 0.00, "currency": "EUR", "min_order_amount": 75.00, "position": 2 }
]
}
}
Zone 2 : United States — tarifs en USD
{
"name": "create_shipping_zone",
"arguments": {
"name": "United States",
"country_codes": ["US"],
"position": 1,
"rates": [
{ "name": "Standard", "price": 10.00, "currency": "USD", "position": 0 },
{ "name": "Express", "price": 25.00, "currency": "USD", "position": 1 }
]
}
}
Zone 3 : Rest of world — catch-all
{
"name": "create_shipping_zone",
"arguments": {
"name": "Rest of world",
"is_rest_of_world": true,
"country_codes": [],
"position": 99,
"rates": [
{ "name": "International standard", "price": 20.00, "currency": "" }
]
}
}
currency: "" signifie "applique-toi à n'importe quelle devise" — la rate s'affiche que le visiteur paye en EUR, USD, GBP ou autre. Pratique pour les zones "international" où tu factures un tarif unique converti à la volée par Stripe.
Résultat au checkout
- Un visiteur français qui achète 30 € voit "Standard 5 €" + "Express 15 €".
- Le même visiteur qui dépasse 75 € voit "Free over 75 €" en plus, déjà à 0.
- Un visiteur US voit "Standard $10" + "Express $25".
- Un visiteur japonais (pas dans EU ni US) voit "International standard 20" (devise = celle du produit, par exemple EUR).
Stratégies de pricing shipping
Quelques patterns qui marchent bien chez les opérateurs Trackily :
- Free shipping over X — la mécanique de loin la plus efficace pour augmenter le panier moyen. Régle ton seuil à ~1.5× ton prix unitaire moyen.
- Express comme upsell visible — afficher l'option Express à côté du Standard transforme 10 à 15 % des acheteurs en payeurs Express, marge nette.
- Shipping inclus dans le prix — au lieu de facturer 5 € de shipping sur un produit à 25 €, vends-le à 30 € avec "Livraison gratuite" en argument hero. Conversion +10-20 % observée sur les supplements.
- Zones par bandes tarifaires — au lieu de 1 zone EU avec 1 rate à 5 €, fais 2 zones (Zone EU-proche : FR/BE/DE à 4 € ; Zone EU-loin : ES/PT/GR/PL à 8 €). Plus de réalisme, moins de pertes sur les longues distances.
Erreurs courantes
- "A rest-of-world zone already exists" — il ne peut y en avoir qu'une. Édite l'existante au lieu d'en créer une deuxième.
- "Le visiteur ne voit aucune rate" — la zone qui match a 0 rate active dans la bonne currency, OU les filtres min/max_order_amount excluent toutes les rates pour ce subtotal. Le fallback
native_products.shipping_costprend alors le relais (si configuré). - "Mon shipping reste à 0 alors que j'ai configuré une rate" —
free_shipping_abovesur le produit est sans doute déclenché. Ou lemin_order_amountd'une rate "free" est dépassé. - "Plusieurs zones contiennent le même pays" — la première qui match gagne (
position ASC, id ASC). Réordonne en jouant surposition. Évite si possible — c'est trompeur. - "La currency vide ne fonctionne pas pour ma rate" — vérifie que ton produit a bien une
currencysetté (USD/EUR…) ; les rates en currency vide héritent de la currency du produit pour le calcul du total, ne se substituent pas à l'absence de currency. - "
delete_shipping_zoneme dit qu'il a tout supprimé mais les commandes historiques affichent encore la rate" — c'est attendu, les colonnesshipping_rate_name+shipping_zone_idsont snapshotées sur la commande. Le zone_id passe à NULL mais le name est conservé.
Voir aussi
- products.md —
shipping_costetfree_shipping_aboveau niveau produit (fallback). - orders.md — comment
shipping_costetshipping_rate_namesont persistés sur la commande. - Admin UI → Settings — l'onglet Shipping zones dans Settings.
- MCP
list_shipping_zones/create_shipping_zone/create_shipping_rate— référence complète des outils Tier-2.