Refunds
TL;DR : rembourse une commande Stripe ou PayPal en un appel —
refund_native_ordercalle Stripe/PayPal API, restocke la marchandise (sur refund total seulement), passe lepayment_statusenrefunded(oupartial_refund), et écrit une ligne audit dansorder_transactions. Tier-2, irreversible. Tu peux passer unamountpour un partial.

Concept
Le refund est l'opération inverse du paiement. Trackily ne stocke pas l'argent — il route l'API call vers Stripe ou PayPal, et synchronise sa base avec le résultat.
Trois choses se passent en simultané dans un refund réussi :
- Côté provider (Stripe ou PayPal) : la
Refundest créée sur le payment_intent / capture original. L'argent retombe sur le moyen de paiement source (CB, IBAN, solde PayPal). Le client est notifié par le provider directement (email Stripe ou PayPal). - Côté
orders:payment_statuspasse àrefunded(refund total) oupartial_refund(refund partiel).updated_atrafraîchi. - Côté
order_transactions: nouvelle ligne auditkind='refund',amount,currency,provider,provider_reference(id du refund Stripe/PayPal),status='success',raw_response(la réponse JSON complète du provider).
Full refund vs partial refund
| Full refund | Partial refund | |
|---|---|---|
amount param |
omis OU égal au total | < total |
payment_status après |
refunded |
partial_refund |
| Restocking variantes | oui | non |
| Notification client | oui (par Stripe/PayPal) | oui (par Stripe/PayPal) |
| Re-refund possible ? | non (status='refunded' refuse) | oui (re-refund le reste) |
L'opération est automatique côté provider — le payment_intent.id (Stripe) ou capture.id (PayPal) stocké dans orders.payment_reference est tout ce dont on a besoin pour appeler POST /refunds (Stripe) ou POST /payments/captures/{id}/refund (PayPal).
Restocking : seulement sur refund total
Trackily restocke les variantes uniquement quand le refund est total (amount >= total). Pourquoi ? Parce qu'un partial refund typique correspond à :
- une réduction commerciale (geste après réclamation) — la marchandise reste chez le client
- un produit cassé en transit où on rembourse une partie (le client garde le reste)
- une livraison incomplète où on rembourse l'item manquant — pas physique côté stock
Dans aucun de ces cas le stock ne doit revenir en magasin. Le restock total se justifie uniquement quand le client renvoie tout (return-then-refund — voir fulfillment.md fulfillment_status='returned').
Si tu veux explicitement restocker sur un partial, fais-le manuellement via l'admin variante.
Pré-requis
Avant qu'un refund soit possible :
payment_status∈{paid, partial_refund}— le refund n'a pas de sens sur unpending(rien n'a été capturé) ourefunded(déjà tout remboursé).payment_method∈{stripe, paypal}— COD n'a pas de refund automatisé (tu rembourses en cash en main propre, puis tucancel_native_orderpour libérer le stock).payment_account_idetpayment_referencesont non-NULL — sans ça, on ne sait pas quel compte appeler ni quel payment_intent / capture cibler.- Le compte (
payment_accounts) estis_active=true— un compte désactivé ne peut pas faire de refund.
Si une de ces conditions échoue, le tool refuse avec un message explicite.
Comment faire (UI + MCP)
Via l'admin UI
- Commerce → Orders → clique sur la commande.
- En haut à droite, bouton Refund.
- Modal :
- Amount — pré-rempli avec le total restant remboursable. Tu peux baisser pour un partial.
- Reason — note interne (apparaît dans
order_transactions.raw_response).
- Clique Refund now.
- Pendant 2 à 5 secondes, Trackily appelle Stripe/PayPal.
- Notification "Refund successful — $25 returned to original payment method".
L'écran de détail se rafraîchit, payment_status est mis à jour, une nouvelle ligne apparaît dans la section Transactions.
Via MCP
Refund total :
{
"name": "refund_native_order",
"arguments": {
"order_id": 87
}
}
Refund partiel ($10 sur un total $55.88) :
{
"name": "refund_native_order",
"arguments": {
"order_id": 87,
"amount": 10.00
}
}
Réponse Tier-2 première étape (preview + confirm_token) :
{
"status": "confirmation_required",
"tool": "refund_native_order",
"preview": {
"order_id": 87,
"order_number": "TR-000087",
"payment_method": "stripe",
"order_total": 55.88,
"currency": "EUR",
"refund_amount": 10.00,
"refund_kind": "partial",
"note": "Refund is irreversible — the buyer will be notified by Stripe/PayPal directly."
},
"confirm_token": "tk_…"
}
Re-soumets avec le confirm_token :
{
"name": "refund_native_order",
"arguments": {
"order_id": 87,
"amount": 10.00,
"confirm_token": "tk_…"
}
}
Réponse de succès :
{
"status": "ok",
"tool": "refund_native_order",
"order_id": 87,
"order_number": "TR-000087",
"refund_amount": 10.00,
"refund_currency": "EUR",
"refund_reference": "re_3OabcXYZ",
"new_payment_status": "partial_refund"
}
Sous le capot : Stripe vs PayPal
Stripe
const refund = await stripeApi.createRefund(
account, // effectivePaymentAccount(payment_accounts row)
order.payment_reference, // payment_intent.id, ex 'pi_3OabcXYZ'
requested, // amount en unité majeure (10.00) — converti en cents
order.currency // 'EUR'
);
createRefund POST sur https://api.stripe.com/v1/refunds avec le body :
payment_intent=pi_3OabcXYZ&amount=1000
Si amount est absent → full refund.
Réponse Stripe (extrait) :
{
"id": "re_3OabcXYZ",
"amount": 1000,
"currency": "eur",
"payment_intent": "pi_3OabcXYZ",
"status": "succeeded",
"reason": null
}
Trackily extrait :
refundAmount = refund.amount / 100(cents → unité majeure)refundCurrency = refund.currency.toUpperCase()refundRef = refund.id
PayPal
const refund = await paypalApi.refundPayPalCapture(
account, // effectivePaymentAccount
order.payment_reference, // capture.id, ex '7XW12345AB'
requested, // amount en unité majeure
order.currency
);
Sous le capot, PayPal v2 :
POST /v2/payments/captures/{capture_id}/refund
Authorization: Bearer <oauth_access_token>
Body: { amount: { value: '10.00', currency_code: 'EUR' } }
Réponse type :
{
"id": "1AB23456C7890123D",
"amount": { "value": "10.00", "currency_code": "EUR" },
"status": "COMPLETED"
}
Trackily extrait :
refundAmount = parseFloat(refund.amount.value)refundCurrency = refund.amount.currency_code.toUpperCase()refundRef = refund.id
Détection full vs partial
const isFull = refundAmount >= parseFloat(order.total);
const newStatus = isFull ? 'refunded' : 'partial_refund';
Le check est en réel : refundAmount est le montant exact remboursé (renvoyé par le provider), pas ce que tu as demandé. Stripe/PayPal peuvent arrondir au cent près sur des conversions de devises. Toujours utiliser la réponse comme vérité.
Ledger : order_transactions
Chaque refund insère une ligne audit :
{
"order_id": 87,
"kind": "refund",
"amount": 10.00,
"currency": "EUR",
"status": "success",
"provider": "stripe",
"provider_reference": "re_3OabcXYZ",
"raw_response": {
"source": "mcp_autopilot",
"token_id": 42,
"requested_amount": 10.00
}
}
Les raw_response historiques (capture, refund, dispute, void) racontent la vie complète de la commande pour les disputes ou les audits comptables.
Pour lister toutes les transactions d'une commande, get_native_order_detail les inclut dans la réponse.
Exemples concrets
1. Client mécontent, geste commercial -20 %
Commande TR-000087, total 100 €, client a reçu un produit légèrement abîmé. Tu choisis de rembourser 20 € sans demander le retour :
{
"name": "refund_native_order",
"arguments": { "order_id": 87, "amount": 20.00 }
}
Status final : partial_refund. Stock non touché. Client garde son produit.
2. Return-then-refund
Le client renvoie le produit. Tu reçois le colis :
fulfill_native_orderavecfulfillment_status='returned'(voir fulfillment.md).refund_native_ordersansamount(full refund).- Trackily appelle Stripe → refund complet →
payment_status='refunded'+ variantes restockées.
3. Refund partiel itératif
Le client commande 3 items mais 1 manque. Tu rembourses le manquant (20 €) :
{
"name": "refund_native_order",
"arguments": { "order_id": 87, "amount": 20.00 }
}
partial_refund. Plus tard, autre problème, tu rembourses 10 € de plus :
{
"name": "refund_native_order",
"arguments": { "order_id": 87, "amount": 10.00 }
}
Toujours partial_refund (10 + 20 = 30 < 100). Une 3e demande de 70 € → refunded (refund total atteint).
4. Refund sur abonnement Stripe
Un abonnement actif est représenté par une commande initiale paid + des commandes filles successives. Refunder la commande initiale ne cancel pas l'abonnement Stripe (qui continue de facturer). Pour stopper, utilise cancel_subscription séparément. Ordre recommandé : annule la subscription d'abord, puis refund la dernière facturation si justifié.
Erreurs courantes
- "Cannot refund order in status 'pending'" — il n'y a rien à rembourser, le paiement n'a pas été capturé. Use
cancel_native_orderà la place. - "Cannot refund order in status 'refunded'" — déjà tout remboursé. Si tu veux refunder en plus, c'est qu'il y a une nouvelle capture (rare). Crée une nouvelle commande à la place.
- "Automated refund not supported for payment_method 'cod'" — comportement attendu. Rembourse en cash en main propre, puis cancel la commande pour libérer le stock.
- "Order is missing payment_account_id or payment_reference" — la commande n'a pas été correctement liée au compte de paiement (peut arriver sur des migrations anciennes). Refund manuellement via le dashboard Stripe/PayPal, puis update
payment_statusen SQL direct. - "Stripe POST /refunds failed: charge_already_refunded" — Stripe te dit qu'il a déjà été remboursé. Resync : check le statut Stripe du payment_intent, update
payment_statuslocalement. - "PayPal refund failed: capture_already_refunded" — idem, PayPal.
- "Refund OK mais le stock n'est pas restocké" — c'est un partial refund. Restock manuellement via l'admin variante si tu l'as vraiment récupéré.
- "Refund partiel suivi d'un refund complet ne restocke pas" — édge case. Le restock ne se déclenche qu'au refund qui fait basculer en
refunded. Si tu as fait un partial puis un complet, le second restock devrait quand même se déclencher (basculement partial_refund → refunded). Si ce n'est pas le cas, c'est un bug à reporter. - "Client conteste alors qu'on n'a pas refundé" — le client a peut-être fait un chargeback CB ou un PayPal dispute. Côté Trackily, tu vois une ligne
order_transactions kind='dispute'(créée par le webhookcharge.dispute.created). À traiter via le dashboard Stripe/PayPal.
Edge cases à connaître
- Refund quand le webhook
payment_intent.succeededn'a pas fini de tourner — race possible. Le refund vérifiepayment_status='paid', donc tant que le webhook n'a pas marquépaid, le refund refuse. Attends 30 secondes et retry. - Refund > total à cause d'une conversion devise — possible théoriquement si tu rembourses en EUR un paiement initialement en USD avec un taux qui a bougé. Stripe arrondit au cent ; PayPal aussi. Trackily fait confiance à
refund.amount(la vérité du provider). - Refund d'une commande dont le
payment_accounta été disconnecté — Trackily essaie quand même. Si le compte OAuth a été révoqué, l'API Stripe répond 401 et le tool propage l'erreur. Solution : reconnecte le compte (Stripe garde la révocation côté merchant, donc tu devras refunder manuellement depuis leur dashboard).
Voir aussi
- orders.md — la state machine
payment_statuset son interaction avec le refund. - fulfillment.md —
fulfillment_status='returned'qui précède souvent un refund. - payment-accounts.md — comment Trackily pioche dans
payment_accountspour authentifier l'appel. - Automizer → Rules — règles automatiques sur les refunds (alerte si > threshold).
- Code source :
autopilot-native-orders-tools.jstoolRefundNativeOrder,stripe-helpers.jscreateRefund,paypal-helpers.jsrefundPayPalCapture.