Trackily Docs

Refunds

TL;DR : rembourse une commande Stripe ou PayPal en un appel — refund_native_order calle Stripe/PayPal API, restocke la marchandise (sur refund total seulement), passe le payment_status en refunded (ou partial_refund), et écrit une ligne audit dans order_transactions. Tier-2, irreversible. Tu peux passer un amount pour un partial.

Order detail avec bouton Refund

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 :

  1. Côté provider (Stripe ou PayPal) : la Refund est 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).
  2. Côté orders : payment_status passe à refunded (refund total) ou partial_refund (refund partiel). updated_at rafraîchi.
  3. Côté order_transactions : nouvelle ligne audit kind='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 un pending (rien n'a été capturé) ou refunded (déjà tout remboursé).
  • payment_method{stripe, paypal} — COD n'a pas de refund automatisé (tu rembourses en cash en main propre, puis tu cancel_native_order pour libérer le stock).
  • payment_account_id et payment_reference sont non-NULL — sans ça, on ne sait pas quel compte appeler ni quel payment_intent / capture cibler.
  • Le compte (payment_accounts) est is_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

  1. Commerce → Orders → clique sur la commande.
  2. En haut à droite, bouton Refund.
  3. 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).
  4. Clique Refund now.
  5. Pendant 2 à 5 secondes, Trackily appelle Stripe/PayPal.
  6. 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 :

  1. fulfill_native_order avec fulfillment_status='returned' (voir fulfillment.md).
  2. refund_native_order sans amount (full refund).
  3. 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_status en 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_status localement.
  • "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 webhook charge.dispute.created). À traiter via le dashboard Stripe/PayPal.

Edge cases à connaître

  • Refund quand le webhook payment_intent.succeeded n'a pas fini de tourner — race possible. Le refund vérifie payment_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_account a é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_status et son interaction avec le refund.
  • fulfillment.mdfulfillment_status='returned' qui précède souvent un refund.
  • payment-accounts.md — comment Trackily pioche dans payment_accounts pour authentifier l'appel.
  • Automizer → Rules — règles automatiques sur les refunds (alerte si > threshold).
  • Code source : autopilot-native-orders-tools.js toolRefundNativeOrder, stripe-helpers.js createRefund, paypal-helpers.js refundPayPalCapture.