Skip to main content

Introduction

Gérer les remboursements est une partie essentielle de tout système de paiement. Que ce soit pour une annulation client, un litige ou une erreur, votre application doit pouvoir rembourser de manière fiable. Ce guide couvre les remboursements Stripe dans une API Node.js.

Types de remboursements

Remboursement total

"`typescript"`
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
});

async function refundFull(paymentIntentId: string) {
  const refund = await stripe.refunds.create({
    payment_intent: paymentIntentId,
    // Sans montant = remboursement total
  });

  return refund;
}

Remboursement partiel

"`typescript"`
async function refundPartial(paymentIntentId: string, amountInCents: number) {
  const refund = await stripe.refunds.create({
    payment_intent: paymentIntentId,
    amount: amountInCents, // Ex: 5000 = 50€
  });

  return refund;
}

Remboursements multiples

Vous pouvez effectuer plusieurs remboursements partiels sur un même paiement :
"`typescript"`
// Premier remboursement de 30€
await stripe.refunds.create({
  payment_intent: 'pi_xxx',
  amount: 3000,
});

// Deuxième remboursement de 20€
await stripe.refunds.create({
  payment_intent: 'pi_xxx',
  amount: 2000,
});

// Total remboursé = 50€

Implémentation complète

Use Case : Annulation de réservation

"`typescript"`
interface CancellationResult {
  amountToRefund: number;
  amountToKeep: number;
  refundPercentage: number;
  appliedRule: string;
}

async function processRefund(
  reservationId: string,
  calculation: CancellationResult
): Promise<Stripe.Refund | null> {
  // 1. Récupérer le paiement depuis votre DB
  const payment = await db.payment.findFirst({
    where: {
      reservationId,
      status: 'SUCCEEDED',
      source: 'STRIPE',
    },
  });

  if (!payment || !payment.stripePaymentIntentId) {
    throw new Error('Paiement Stripe non trouvé');
  }

  // 2. Vérifier qu'il y a quelque chose à rembourser
  if (calculation.amountToRefund <= 0) {
    console.log('Aucun montant à rembourser');
    return null;
  }

  // 3. Effectuer le remboursement Stripe
  const refund = await stripe.refunds.create({
    payment_intent: payment.stripePaymentIntentId,
    amount: calculation.amountToRefund,
    reason: 'requested_by_customer',
    metadata: {
      reservationId,
      appliedRule: calculation.appliedRule,
    },
  });

  // 4. Mettre à jour votre base de données
  await db.payment.update({
    where: { id: payment.id },
    data: {
      status: 'REFUNDED',
      refundedAmount: calculation.amountToRefund,
    },
  });

  return refund;
}

Règles de remboursement (exemple métier)

Voici un exemple de règles de remboursement selon le délai avant un événement :
"`typescript"`
interface RefundRule {
  name: string;
  daysBeforeEvent: number;
  refundPercentage: number;
}

const REFUND_RULES: RefundRule[] = [
  { name: 'DAYS_30_OR_MORE', daysBeforeEvent: 30, refundPercentage: 100 },
  { name: 'DAYS_21_TO_29', daysBeforeEvent: 21, refundPercentage: 70 },
  { name: 'DAYS_8_TO_20', daysBeforeEvent: 8, refundPercentage: 40 },
  { name: 'DAYS_7_OR_LESS', daysBeforeEvent: 0, refundPercentage: 0 },
];

function calculateRefund(totalPrice: number, eventDate: Date): CancellationResult {
  const now = new Date();
  const daysUntil = Math.floor(
    (eventDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
  );

  // Trouver la règle applicable
  const rule = REFUND_RULES.find(r => daysUntil >= r.daysBeforeEvent)
    ?? REFUND_RULES[REFUND_RULES.length - 1];

  const amountToRefund = Math.floor(totalPrice * (rule.refundPercentage / 100));
  const amountToKeep = totalPrice - amountToRefund;

  return {
    amountToRefund,
    amountToKeep,
    refundPercentage: rule.refundPercentage,
    appliedRule: rule.name,
  };
}

Gérer les webhooks de remboursement

"`typescript"`
async function handleRefundWebhook(event: Stripe.Event) {
  if (event.type !== 'charge.refunded') return;

  const charge = event.data.object as Stripe.Charge;

  // Récupérer les infos du remboursement
  const refundAmount = charge.amount_refunded;
  const paymentIntentId = charge.payment_intent as string;

  // Mettre à jour votre DB
  const payment = await db.payment.findFirst({
    where: { stripePaymentIntentId: paymentIntentId },
  });

  if (payment) {
    await db.payment.update({
      where: { id: payment.id },
      data: {
        status: 'REFUNDED',
        refundedAmount: refundAmount,
      },
    });

    // Notifier le client
    await sendRefundConfirmationEmail(payment.userId, refundAmount);
  }
}

Gestion des litiges (Disputes)

Qu’est-ce qu’un litige ?

Un litige survient quand un client conteste un paiement auprès de sa banque (chargeback). Stripe vous notifie via webhook.

Recevoir les notifications de litige

"`typescript"`
async function handleDisputeWebhook(event: Stripe.Event) {
  switch (event.type) {
    case 'charge.dispute.created':
      await handleDisputeCreated(event.data.object as Stripe.Dispute);
      break;

    case 'charge.dispute.updated':
      await handleDisputeUpdated(event.data.object as Stripe.Dispute);
      break;

    case 'charge.dispute.closed':
      await handleDisputeClosed(event.data.object as Stripe.Dispute);
      break;
  }
}

async function handleDisputeCreated(dispute: Stripe.Dispute) {
  console.log(`Litige créé: ${dispute.id}`);
  console.log(`Montant: ${dispute.amount / 100}€`);
  console.log(`Raison: ${dispute.reason}`);

  // Alerter l'équipe
  await sendAlertToTeam({
    type: 'DISPUTE_CREATED',
    disputeId: dispute.id,
    amount: dispute.amount,
    reason: dispute.reason,
  });

  // Mettre à jour le paiement
  const chargeId = dispute.charge as string;
  await db.payment.updateMany({
    where: { stripeChargeId: chargeId },
    data: { status: 'DISPUTING' },
  });
}

Répondre à un litige

"`typescript""
async function respondToDispute(disputeId: string, evidence: object) {
  const dispute = await stripe.disputes.update(disputeId, {
    evidence: {
      customer_name: 'Jean Dupont',
      customer_email_address: '[email protected]',
      product_description: 'Réservation voyage Istanbul',
      // Documents de preuve
      customer_signature: 'file_xxx', // Upload préalable
      receipt: 'file_yyy',
      // ...
    },
    submit: true, // Soumettre la réponse
  });

  return dispute;
}

Types de raisons de litige

| Raison | Description | Action recommandée |
|--------|-------------|-------------------|
| `fraudulent` | Utilisation frauduleuse | Fournir preuves d'authenticité |
| `duplicate` | Double facturation | Prouver l'unicité |
| `product_not_received` | Produit non reçu | Preuve de livraison |
| `product_unacceptable` | Produit non conforme | Conditions de vente |
| `subscription_canceled` | Abo annulé mais facturé | Historique d'annulation |
| `unrecognized` | Transaction non reconnue | Détails de la transaction |

Idempotence des remboursements

Implémentez l’idempotence pour éviter les doubles remboursements :
"`typescript"`
async function safeRefund(
  paymentId: string,
  amountToRefund: number
): Promise<Stripe.Refund | null> {
  // Verrouiller le paiement pour éviter les doublons
  const payment = await db.payment.findUnique({
    where: { id: paymentId },
  });

  if (!payment) {
    throw new Error('Paiement non trouvé');
  }

  // Vérifier si déjà remboursé
  if (payment.status === 'REFUNDED') {
    console.log(`Paiement ${paymentId} déjà remboursé, ignoré`);
    return null;
  }

  // Vérifier le montant restant à rembourser
  const remainingAmount = payment.paidAmount - (payment.refundedAmount ?? 0);
  if (amountToRefund > remainingAmount) {
    throw new Error(`Montant demandé (${amountToRefund}) > restant (${remainingAmount})`);
  }

  // Effectuer le remboursement avec clé d'idempotence
  const refund = await stripe.refunds.create(
    {
      payment_intent: payment.stripePaymentIntentId!,
      amount: amountToRefund,
    },
    {
      idempotencyKey: `refund-${paymentId}-${amountToRefund}`,
    }
  );

  // Mettre à jour la DB
  await db.payment.update({
    where: { id: paymentId },
    data: {
      refundedAmount: (payment.refundedAmount ?? 0) + amountToRefund,
      status: remainingAmount - amountToRefund === 0 ? 'REFUNDED' : 'PARTIALLY_REFUNDED',
    },
  });

  return refund;
}

Erreurs courantes

Montant trop élevé

"`typescript"`
try {
  await stripe.refunds.create({
    payment_intent: 'pi_xxx',
    amount: 100000, // Plus que le montant original
  });
} catch (error) {
  if (error.code === 'amount_too_large') {
    console.error('Le montant dépasse le montant original');
  }
}

Paiement déjà remboursé

"`typescript"`
try {
  await stripe.refunds.create({
    payment_intent: 'pi_xxx', // Déjà remboursé
  });
} catch (error) {
  if (error.code === 'charge_already_refunded') {
    console.error('Ce paiement a déjà été remboursé');
  }
}

Checklist production

– [ ] Règles de remboursement définies et documentées
– [ ] Idempotence implémentée
– [ ] Webhooks configurés (`charge.refunded`, `charge.dispute.*`)
– [ ] Alertes pour les litiges
– [ ] Logging de tous les remboursements
– [ ] Tests des cas limites (partiel, multiple, erreurs)

Conclusion

Les remboursements Stripe sont simples techniquement mais nécessitent une bonne gestion métier :
– Définissez vos règles de remboursement clairement
– Implémentez l’idempotence pour éviter les doublons
– Gérez les litiges proactivement
– Loggez tout pour l’audit

 

📚 Série complète : Maîtriser Stripe avec Node.js et React

  • Chapitre 1 : Configuration initiale Intégrer Stripe dans React/Next.js Premier formulaire de paiement et installation.

  • Chapitre 2 : Automatisation Gérer les webhooks Stripe dans Node.js Recevoir et traiter les événements de paiement en temps réel.

  • Chapitre 3 : Stratégie de Paiement  (Précédent) Stripe Checkout vs Elements : Quel choix pour votre projet ? Comparatif complet pour choisir la meilleure UX.

  • Chapitre 4 : Gestion après-vente (Vous êtes ici) Implémenter les remboursements Stripe Gérer les annulations, les remboursements et les litiges.

  • Chapitre 5 : Concepts Avancés (Suivant) PaymentIntent et capture différée Autoriser les paiements et les capturer plus tard.

Prochaine étape recommandée :

[Chapitre 5 – PaymentIntent et capture différée](/blog/chapitre-5-payment-intent-capture-differee) pour maîtriser le flux authorize-then-capture.