ITSkillsCenter
Business Digital

Refund flows multi-PSP : automatiser les remboursements pas-à-pas

15 دقائق للقراءة

📍 Lecture connexe : Stripe, Paystack, Flutterwave et Wave en 2026 : intégrer un processeur de paiement — pour la vue d’ensemble du paysage paiement.

Une boutique sérieuse rembourse en moyenne 3 à 8 % de son chiffre d’affaires sur l’année (annulations, fraudes, retours produit, erreurs de livraison). Sur une plateforme multi-PSP, ce flux de remboursements est piégeux : chaque processeur a son endpoint, son timing, son format de réponse, et la coordination entre la commande remboursée, le PSP cible et la comptabilité interne demande une couche d’abstraction. Ce tutoriel construit un service de remboursement unifié qui prend en charge Stripe, Paystack, Flutterwave et Wave avec une API interne stable, une machine d’état claire et une réconciliation comptable automatique.

Prérequis

  • Node.js 22 LTS et PostgreSQL 16+, ou stack équivalente
  • Comptes test sur les PSP que vous voulez intégrer (au moins deux pour valider l’unification)
  • Lecture préalable des tutoriels Stripe, Paystack, Flutterwave et Wave si pas encore intégrés
  • Niveau attendu : avancé — vous avez déjà fait au moins une intégration paiement complète
  • Temps estimé : environ 100 minutes

Étape 1 — Comprendre la diversité des modèles refund

Les quatre PSP diffèrent dans leur modélisation des remboursements. Stripe expose un objet Refund de premier niveau avec son propre cycle de vie (pending, succeeded, failed, canceled) et ses propres webhooks charge.refunded, refund.updated. Paystack offre un endpoint POST /refund qui prend la transaction d’origine ; le remboursement est immédiat ou différé selon le rail (instantané sur les cartes locales, jusqu’à 21 jours sur certaines cartes internationales). Flutterwave traite les refunds via POST /transactions/{id}/refund et confirme par webhook refund. Wave Business utilise sa Payout API pour les remboursements vers le numéro source du paiement initial.

Au-delà des différences techniques, la sémantique de remboursement varie aussi : Stripe permet les refunds partiels en passant un montant ; Paystack et Flutterwave aussi mais avec des règles spécifiques sur les frais ; Wave ne fait que des remboursements complets vers le compte d’origine. Cette diversité justifie une couche d’abstraction.

Étape 2 — Modéliser la table Refund applicative

La table refunds agrège les remboursements de tous les PSP avec un schéma commun. Chaque ligne pointe vers la transaction d’origine et stocke les références PSP spécifiques.

CREATE TYPE refund_status AS ENUM (
  'requested', 'pending_psp', 'succeeded', 'failed', 'cancelled'
);

CREATE TABLE refunds (
  id BIGSERIAL PRIMARY KEY,
  refund_uuid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
  transaction_id BIGINT NOT NULL REFERENCES transactions(id),
  psp VARCHAR(20) NOT NULL,
  psp_refund_id VARCHAR(128),
  amount_cents BIGINT NOT NULL,
  currency CHAR(3) NOT NULL,
  reason VARCHAR(64) NOT NULL,
  initiated_by VARCHAR(64) NOT NULL,
  status refund_status NOT NULL DEFAULT 'requested',
  raw_payload JSONB,
  requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at TIMESTAMPTZ
);

CREATE INDEX idx_refunds_status ON refunds (status, requested_at);
CREATE INDEX idx_refunds_transaction ON refunds (transaction_id);

Quelques décisions structurantes. Le refund_uuid est l’identifiant exposé en API et utilisé comme clé d’idempotence côté PSP — il garantit qu’un retry réseau ne crée pas un second refund. Le champ reason prend des valeurs énumérées (customer_request, fraud, service_failure, duplicate_charge, other) pour faciliter les statistiques et les politiques. Le champ initiated_by trace qui a déclenché le refund — un humain (email opérateur) ou un système (auto_fraud_detection, customer_self_service).

Étape 3 — Définir l’interface PSP unifiée

// src/refunds/psp-interface.ts
export interface RefundResult {
  pspRefundId: string
  status: 'pending_psp' | 'succeeded' | 'failed'
  rawPayload: object
}

export interface PSPAdapter {
  refund(opts: {
    pspTransactionId: string
    amountCents: number
    currency: string
    idempotencyKey: string
    reason?: string
  }): Promise<RefundResult>
}

Cette interface est volontairement minimaliste. Elle prend un identifiant de transaction PSP, un montant, une devise, une clé d’idempotence, et retourne un identifiant de refund PSP avec un statut. Tout le reste (les particularités de chaque API) est encapsulé dans les implémentations concrètes.

Étape 4 — Implémenter les adapters Stripe et Paystack

// src/refunds/stripe-adapter.ts
import Stripe from 'stripe'
import { PSPAdapter, RefundResult } from './psp-interface'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2026-04-22.dahlia',
})

export const stripeAdapter: PSPAdapter = {
  async refund({ pspTransactionId, amountCents, idempotencyKey, reason }): Promise<RefundResult> {
    const r = await stripe.refunds.create(
      {
        payment_intent: pspTransactionId,
        amount: amountCents,
        reason: reason as Stripe.RefundCreateParams.Reason | undefined,
      },
      { idempotencyKey },
    )
    return {
      pspRefundId: r.id,
      status: r.status === 'succeeded' ? 'succeeded' : r.status === 'failed' ? 'failed' : 'pending_psp',
      rawPayload: r as any,
    }
  },
}
// src/refunds/paystack-adapter.ts
import { paystack } from '../paystack'
import { PSPAdapter, RefundResult } from './psp-interface'

export const paystackAdapter: PSPAdapter = {
  async refund({ pspTransactionId, amountCents, idempotencyKey }): Promise<RefundResult> {
    const r = await paystack.post('/refund', {
      transaction: pspTransactionId,
      amount: amountCents,
      // Paystack n'a pas de header idempotency-key ; on utilise la merchant_note pour traçabilité
      merchant_note: `idem:${idempotencyKey}`,
    })
    const data = r.data.data
    return {
      pspRefundId: String(data.id),
      status: data.status === 'processed' ? 'succeeded' : 'pending_psp',
      rawPayload: data,
    }
  },
}

L’adapter Paystack illustre une subtilité : l’API ne fournit pas de header d’idempotence comme Stripe, donc on s’appuie sur la combinaison (transaction, amount) qui est naturellement idempotente côté Paystack — un appel POST /refund avec les mêmes valeurs sur une transaction déjà partiellement remboursée échoue avec un code clair plutôt que de créer un doublon. La merchant_note sert de traçabilité interne pour relier le refund Paystack au refund applicatif via la clé.

Étape 5 — Adapters Flutterwave et Wave

// src/refunds/flutterwave-adapter.ts
import axios from 'axios'
import { PSPAdapter, RefundResult } from './psp-interface'

export const flutterwaveAdapter: PSPAdapter = {
  async refund({ pspTransactionId, amountCents, idempotencyKey }): Promise<RefundResult> {
    const r = await axios.post(
      `${process.env.FLW_BASE_URL}/transactions/${pspTransactionId}/refund`,
      { amount: amountCents / 100, comments: `idem:${idempotencyKey}` },
      { headers: { Authorization: `Bearer ${process.env.FLW_SECRET_KEY}` } },
    )
    const data = r.data.data
    return {
      pspRefundId: String(data.id),
      status: data.status === 'completed' ? 'succeeded' : 'pending_psp',
      rawPayload: data,
    }
  },
}
// src/refunds/wave-adapter.ts
import { wave } from '../wave'
import { PSPAdapter, RefundResult } from './psp-interface'

export const waveAdapter: PSPAdapter = {
  async refund({ pspTransactionId, amountCents, currency, idempotencyKey }): Promise<RefundResult> {
    // Wave : refund = payout vers le receive_number de la session originale
    const session = await wave.get(`/v1/checkout/sessions/${pspTransactionId}`)
    const receiveNumber = session.data.when_completed?.payment_method_details?.account_number
    if (!receiveNumber) throw new Error('cannot determine refund target')

    const r = await wave.post('/v1/payout', {
      amount: String(amountCents / 100),
      currency,
      receive_number: receiveNumber,
      idempotency_key: idempotencyKey,
    })
    return {
      pspRefundId: r.data.id,
      status: r.data.status === 'completed' ? 'succeeded' : 'pending_psp',
      rawPayload: r.data,
    }
  },
}

L’adapter Wave est le plus complexe car Wave ne fait pas de « refund » au sens technique : on émet un payout depuis le solde commerçant vers le numéro mobile money qui avait payé. On récupère donc d’abord le numéro source via la session de paiement originale, puis on déclenche le payout. Si la session ne fournit pas le numéro source (cas rare des paiements par redirection vers un wallet tiers), le refund n’est pas automatisable et bascule en traitement manuel.

Étape 6 — Coordonner via un service de refund

// src/refunds/refund-service.ts
import { Pool } from 'pg'
import { stripeAdapter, paystackAdapter, flutterwaveAdapter, waveAdapter } from './adapters'
const pool = new Pool()

const adapters = {
  stripe: stripeAdapter,
  paystack: paystackAdapter,
  flutterwave: flutterwaveAdapter,
  wave: waveAdapter,
}

export async function refundTransaction(opts: {
  transactionId: number
  amountCents: number
  reason: string
  initiatedBy: string
}) {
  const tx = await pool.query(
    `SELECT id, psp, psp_transaction_id, currency FROM transactions WHERE id = $1`,
    [opts.transactionId],
  ).then(r => r.rows[0])
  if (!tx) throw new Error('transaction not found')

  // Créer le refund applicatif (status=requested)
  const refund = await pool.query(
    `INSERT INTO refunds (transaction_id, psp, amount_cents, currency, reason, initiated_by)
     VALUES ($1, $2, $3, $4, $5, $6) RETURNING refund_uuid, id`,
    [tx.id, tx.psp, opts.amountCents, tx.currency, opts.reason, opts.initiatedBy],
  ).then(r => r.rows[0])

  await pool.query(`UPDATE refunds SET status = 'pending_psp' WHERE id = $1`, [refund.id])

  try {
    const result = await adapters[tx.psp as keyof typeof adapters].refund({
      pspTransactionId: tx.psp_transaction_id,
      amountCents: opts.amountCents,
      currency: tx.currency,
      idempotencyKey: refund.refund_uuid,
      reason: opts.reason,
    })

    await pool.query(
      `UPDATE refunds SET psp_refund_id = $1, status = $2, raw_payload = $3,
       completed_at = CASE WHEN $2 = 'succeeded' THEN now() ELSE NULL END
       WHERE id = $4`,
      [result.pspRefundId, result.status, result.rawPayload, refund.id],
    )

    return refund.refund_uuid
  } catch (e) {
    await pool.query(`UPDATE refunds SET status = 'failed' WHERE id = $1`, [refund.id])
    throw e
  }
}

Ce service centralise la logique de bout en bout. La création de la ligne refunds avant l’appel PSP est essentielle : si l’appel PSP échoue (réseau, indisponibilité), on a déjà une trace en base qu’un job de retry pourra reprendre. Le refund_uuid sert simultanément d’identifiant API exposé au reste du système et de clé d’idempotence vers le PSP.

Étape 7 — Mettre à jour via webhook

Pour les refunds asynchrones (status pending_psp au moment de l’API), c’est le webhook PSP qui notifie la finalisation. On étend le handler webhook général pour traiter les événements charge.refunded (Stripe), refund.processed (Paystack), refund (Flutterwave), payout.completed (Wave).

// Dans le handler webhook commun
async function processRefundUpdate(psp: string, pspRefundId: string, status: string) {
  const refund = await pool.query(
    `SELECT id FROM refunds WHERE psp = $1 AND psp_refund_id = $2`,
    [psp, pspRefundId],
  ).then(r => r.rows[0])
  if (!refund) return // refund inconnu, peut-être créé hors application

  const newStatus = status === 'succeeded' || status === 'completed' || status === 'processed'
    ? 'succeeded'
    : status === 'failed' ? 'failed' : 'pending_psp'

  await pool.query(
    `UPDATE refunds SET status = $1, completed_at = CASE WHEN $1 = 'succeeded' THEN now() ELSE NULL END
     WHERE id = $2`,
    [newStatus, refund.id],
  )

  // Si succès : déclencher la logique post-refund (notification client, comptabilité)
  if (newStatus === 'succeeded') {
    // ...
  }
}

Étape 8 — Réconcilier la comptabilité

Chaque refund réussi doit alimenter le journal comptable côté commerçant. Trois écritures classiques : débit du compte de chiffre d’affaires (annulation de la vente), crédit du compte client (sortie de trésorerie vers le client), et ajustement éventuel de la TVA collectée. Les outils comptables modernes (Sage, Odoo, QuickBooks, Pennylane) acceptent un format CSV ou un push API pour ces écritures.

La règle d’or est de ne jamais imputer un refund directement à zéro : on conserve toujours la transaction d’origine en chiffre d’affaires et on enregistre le refund comme écriture distincte de signe opposé. Cela permet de garder la traçabilité comptable et de répondre aux contrôles fiscaux qui demandent à voir l’opération brute et son annulation séparément.

Étape 9 — Politique opérationnelle

Au-delà du code, une politique de remboursement saine repose sur trois règles. Premièrement, validation à deux yeux pour les montants élevés : tout refund au-delà d’un seuil (par exemple 100 000 XOF ou 500 EUR) doit être approuvé par une seconde personne avant exécution, ce qui réduit drastiquement le risque de fraude interne. Deuxièmement, journalisation immuable : la table refunds ne doit pas accepter de DELETE ; les annulations passent par un statut cancelled avec motif. Troisièmement, alerte sur volume anormal : une hausse soudaine du nombre de refunds (plus de 20 % de hausse sur 24 heures par rapport à la moyenne 7 jours) déclenche une notification ; cela attrape les bugs qui auto-rembourseraient en boucle ou les tentatives de fraude par compromission de compte.

Étape 10 — Délais de remboursement et communication client

Les délais de remboursement varient considérablement selon le rail. Sur Stripe, un refund sur une carte européenne SEPA est typiquement crédité en 5 à 10 jours ouvrés au compte client ; sur une carte américaine, parfois 21 jours. Sur Paystack, un refund sur carte locale nigériane est instantané ; sur carte internationale, jusqu’à 21 jours selon la banque émettrice. Sur Flutterwave, les délais varient par méthode de paiement : instantané pour Mobile Money, 5 jours pour les cartes. Sur Wave, un payout vers le numéro source est crédité en quelques secondes en zone UEMOA.

Cette disparité doit être communiquée au client. Une page « État de ma demande de remboursement » qui affiche le PSP, la date d’initiation, le délai estimé, et le statut courant désamorce 80 % des contacts au support. L’application interroge périodiquement le PSP (via webhook ou polling) et met à jour l’affichage en temps réel.

Un email de confirmation est envoyé à trois moments : à l’initiation (« nous avons enregistré votre demande »), au passage en statut pending_psp (« le remboursement est en cours auprès de votre banque »), au succès final (« le remboursement a été crédité »). Cette séquence transforme une expérience anxiogène en process maîtrisé du point de vue client.

Étape 11 — Refunds partiels et calcul de TVA

Un refund partiel demande un calcul de TVA correct. Si une commande de 12000 XOF TTC contenait 2000 XOF de TVA et qu’on rembourse 6000 XOF (la moitié), la TVA à reprendre est de 1000 XOF, pas 2000. La logique applicative doit retraiter ce calcul au moment du refund, jamais le recopier brut depuis la transaction d’origine.

function vatForRefund(refundAmount: number, txGrossAmount: number, txVatAmount: number): number {
  return Math.round((refundAmount / txGrossAmount) * txVatAmount)
}

L’arrondi à l’entier est important pour éviter les centimes orphelins en comptabilité. Si le refund représente exactement 50 % du montant brut, la TVA récupérée représente exactement 50 % de la TVA initiale, à un kobo près qu’on absorbe dans le compte d’écart d’arrondi.

Étape 12 — Gérer les refunds frauduleux ou contestés

Tous les refunds ne sont pas légitimes. Un remboursement initié par un compte client compromis doit pouvoir être bloqué a posteriori, et un remboursement émis par erreur doit pouvoir être annulé tant qu’il n’est pas crédité. La machine d’état le prévoit avec le statut cancelled qu’on n’utilise que tant que le PSP ne l’a pas encore exécuté.

Pour Stripe, l’endpoint POST /refunds/{id}/cancel annule un refund qui n’est pas encore traité. Pour les autres PSP, l’annulation n’est pas toujours possible côté API ; on documente alors le motif et on ajuste comptablement par contre-passation. Cette flexibilité doit être réservée à un compte opérateur dédié, jamais accessible aux comptes client en self-service, sous peine d’ouvrir une faille de fraude.

Dans le même esprit, on instrumente une alerte sur volume anormal de refunds par opérateur : si un opérateur émet plus de N refunds par heure ou pour un montant cumulé supérieur à un seuil, on bloque préventivement et on notifie le responsable sécurité. Cette boucle de contrôle interne attrape les cas de compte d’opérateur compromis ou d’employé malhonnête.

Étape 13 — Audit et conformité

Une trace complète des refunds est obligatoire pour les audits comptables et les contrôles fiscaux. La table refunds doit conserver l’historique sur au moins 10 ans dans la plupart des juridictions. On ne supprime jamais une ligne ; les corrections passent par des écritures complémentaires ou des statuts cancelled qui préservent la trace originale.

Un export annuel au format normalisé (CSV avec colonnes attendues par les logiciels comptables et les autorités fiscales) facilite les contrôles. Les outils comptables modernes peuvent générer ce rapport automatiquement à partir de la table de refunds, à condition que les champs soient correctement mappés (compte de chiffre d’affaires, compte de TVA, compte client, etc.).

Erreurs fréquentes

Erreur Cause Solution
Double refund sur la même transaction Pas de clé d’idempotence côté PSP Utiliser refund_uuid comme idempotency key systématiquement
Refund Wave bloqué Numéro source non disponible Vérifier que la session originale a bien account_number avant d’automatiser
Comptabilité décalée Refund enregistré sans contrepartie comptable Push automatique vers l’outil comptable au webhook de succès
Stripe refund pending pendant des jours Délai bancaire normal sur certaines cartes Communiquer 5 à 10 jours ouvrés au client, attendre le webhook final
Refund partiel sur une transaction multiline Allocation montant/ligne non gérée Stocker le détail panier et permettre un refund par ligne avec calcul de TVA

Étape 14 — Refunds en cascade sur marketplaces

Une marketplace Stripe Connect ou Paystack subaccounts a un cas particulier : un refund au client implique aussi le débit de la commission plateforme et du solde vendeur. La logique se complique car ces opérations sont liées dans le PSP.

Sur Stripe Connect, l’option reverse_transfer du refund retire automatiquement les fonds du compte connecté qui les avait reçus. Sans cette option, le commerçant rembourse le client mais le vendeur garde l’argent, ce qui crée un trou financier côté plateforme. La règle est de toujours utiliser reverse_transfer: true sauf cas exceptionnel d’avoir négocié explicitement autre chose avec le vendeur. Limitation à connaître : Stripe ne supporte pas le refund partiel combiné à refund_application_fee=true ou reverse_transfer=true ; pour des refunds partiels précis, il faut faire des appels séparés à POST /v1/application_fees/{id}/refunds et POST /v1/transfers/{id}/reversals.

Côté Paystack, le mécanisme est différent : le refund est imputé proportionnellement au compte plateforme et aux subaccounts en fonction des règles de splitting configurées au moment de la transaction d’origine. La plateforme doit donc s’assurer que ces règles sont conservées et accessibles au moment du refund.

Étape 15 — Reporting consolidé

Une vue unique des refunds tous PSP confondus est ce qui permet de piloter la santé financière d’une plateforme. On expose un dashboard interne qui agrège : taux de refund par PSP, par catégorie de produit, par motif, par opérateur. Une hausse du taux de refund pour un motif spécifique (par exemple service_failure) signale un problème opérationnel à investiguer en amont.

Les données sont rafraîchies en quasi-temps réel à partir de la table refunds. Pour les commerçants à fort volume, on agrège par heure pour limiter le coût des requêtes de dashboard. Une rétention de 24 mois sur les agrégats horaires permet les analyses de tendance saisonnière.

Ressources

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité