ITSkillsCenter
Business Digital

Réconciliation paiements multi-providers FCFA : guide pratique 2026

8 min de lecture

Quand on accepte les paiements via Wave + Orange Money + Free Money + éventuellement MTN MoMo et un agrégateur, la réconciliation comptable et financière devient critique. Sur 1 000 transactions par mois, vous aurez des écarts : webhooks perdus, doublons, transactions validées sans webhook, frais opérateurs non documentés, retours et remboursements. Sans processus de réconciliation rigoureux, vous découvrez ces écarts trop tard — quand votre caisse ne match pas vos commandes. Voici le guide pratique pour réconcilier proprement vos paiements multi-providers en FCFA en 2026.

Ce tutoriel s’inscrit dans notre série Mobile Money. Pour le panorama global, voir notre guide complet API Mobile Money 2026.

Le problème de la réconciliation

Trois sources de vérité doivent matcher en permanence :

  1. Vos commandes internes : ce que votre application a enregistré (statut « paid »)
  2. Les API providers : ce que Wave, OM, Free Money confirment côté serveur
  3. Vos relevés bancaires / mobile money : ce qui est effectivement crédité chez vous

Dans un monde idéal les trois sont identiques. Dans la réalité, des écarts existent. La réconciliation, c’est trouver et résoudre ces écarts quotidiennement.

Sources d’écarts fréquentes

  • Webhook manqué : votre serveur était down 30 secondes, le webhook n’est pas arrivé, statut reste « pending » mais la transaction est validée chez le provider
  • Doublon : utilisateur clique deux fois, sans Idempotency-Key vous créez deux transactions, dont une refuse
  • Frais opérateur : la grille tarifaire n’est pas toujours documentée à 100 %, vous recevez moins que prévu
  • Remboursement non synchronisé : un remboursement opérateur côté commercial n’a pas été reflété dans vos commandes
  • Différence de timezone : les rapports providers en UTC, vos commandes en heure Sénégal — décalages d’un jour
  • Annulation tardive : transaction validée puis annulée par l’utilisateur via support, votre système ne le sait pas

Architecture de réconciliation

Trois jobs automatisés à mettre en place :

  1. Polling proactif (toutes les 10-30 min) : interroger l’API de chaque provider pour les commandes « pending » anciennes et synchroniser leur statut
  2. Réconciliation quotidienne (cron 03h00) : récupérer la liste des transactions de la veille chez chaque provider, comparer avec votre base, identifier les écarts
  3. Réconciliation mensuelle (cron 1er du mois) : comparer le total des transactions vs le total reçu sur le compte mobile money / bancaire, calculer les frais réels, identifier les manquants

Étape 1 — Polling proactif

// src/reconcile/poll-pending.ts
import { db } from "../db/client";
import { orders } from "../db/schema";
import { and, eq, lt } from "drizzle-orm";
import { checkWaveStatus } from "../providers/wave";
import { checkOrangeStatus } from "../providers/orange";
import { checkFreeMoneyStatus } from "../providers/freemoney";

export async function pollPendingOrders() {
  const fifteenMinAgo = new Date(Date.now() - 15 * 60 * 1000);

  const pending = await db.select().from(orders)
    .where(and(
      eq(orders.status, "pending"),
      lt(orders.createdAt, fifteenMinAgo),
    ));

  for (const order of pending) {
    try {
      let status: string;

      if (order.provider === "wave") {
        const r = await checkWaveStatus(order.providerSessionId!);
        status = r.payment_status;
      } else if (order.provider === "orange-money") {
        const r = await checkOrangeStatus(order.providerSessionId!);
        status = r.status;
      } else if (order.provider === "freemoney") {
        const r = await checkFreeMoneyStatus(order.providerSessionId!);
        status = r.status;
      } else {
        continue;
      }

      if (status === "succeeded" || status === "SUCCESS") {
        await db.update(orders).set({ status: "paid", reconciledAt: new Date() })
          .where(eq(orders.id, order.id));
        console.log(`Reconciled ${order.id} → paid`);
      } else if (status === "failed" || status === "FAILED" || status === "EXPIRED") {
        await db.update(orders).set({ status: "failed" })
          .where(eq(orders.id, order.id));
      }
    } catch (e) {
      console.error(`Error polling ${order.id}:`, e);
    }
  }
}

Lancez ce job via systemd timer ou cron toutes les 10-15 minutes.

Étape 2 — Réconciliation quotidienne

Chaque provider expose typiquement un endpoint « list transactions par date ». Vous récupérez la liste de la veille, comparez ligne par ligne, identifiez les écarts.

// src/reconcile/daily.ts
export async function dailyReconciliation(date: Date) {
  const dayStart = startOfDay(date);
  const dayEnd = endOfDay(date);

  // 1. Récupérer côté votre base
  const myOrders = await db.select().from(orders)
    .where(and(
      gte(orders.createdAt, dayStart),
      lte(orders.createdAt, dayEnd),
      eq(orders.status, "paid"),
    ));

  // 2. Récupérer côté providers (en parallèle)
  const [waveTx, orangeTx, freeTx] = await Promise.all([
    listWaveTransactions(dayStart, dayEnd),
    listOrangeTransactions(dayStart, dayEnd),
    listFreeMoneyTransactions(dayStart, dayEnd),
  ]);

  // 3. Indexer par référence externe
  const providerByRef = new Map<string, any>();
  [...waveTx, ...orangeTx, ...freeTx].forEach((t) => {
    providerByRef.set(t.client_reference, t);
  });

  const myByRef = new Map<string, any>();
  myOrders.forEach((o) => myByRef.set(o.id, o));

  // 4. Identifier les écarts
  const discrepancies = [];

  // a) Côté nous mais pas côté provider
  for (const [ref, order] of myByRef) {
    if (!providerByRef.has(ref)) {
      discrepancies.push({ type: "ME_ONLY", orderId: ref, amount: order.amount });
    }
  }

  // b) Côté provider mais pas côté nous (webhook perdu, transaction tardive)
  for (const [ref, tx] of providerByRef) {
    if (!myByRef.has(ref)) {
      discrepancies.push({ type: "PROVIDER_ONLY", ref, amount: tx.amount, provider: tx.provider });
    }
  }

  // c) Montants divergents
  for (const [ref, order] of myByRef) {
    const tx = providerByRef.get(ref);
    if (tx && tx.amount !== order.amount) {
      discrepancies.push({
        type: "AMOUNT_DIFF",
        ref,
        myAmount: order.amount,
        providerAmount: tx.amount,
      });
    }
  }

  // 5. Logger et alerter si écarts
  await db.insert(reconciliationLog).values({
    date: dayStart,
    totalMine: myOrders.reduce((s, o) => s + o.amount, 0),
    totalProvider: [...waveTx, ...orangeTx, ...freeTx].reduce((s, t) => s + t.amount, 0),
    discrepancies: discrepancies,
  });

  if (discrepancies.length > 0) {
    await sendSlackAlert(`Réconciliation ${dayStart.toISOString().slice(0, 10)} : ${discrepancies.length} écarts détectés`);
  }
}

Étape 3 — Réconciliation mensuelle (caisse)

En fin de mois, comparez le cumulé des transactions providers avec ce qui a été crédité sur votre compte mobile money / bancaire :

export async function monthlyReconciliation(yearMonth: string) {
  const [year, month] = yearMonth.split("-").map(Number);
  const monthStart = new Date(year, month - 1, 1);
  const monthEnd = new Date(year, month, 0, 23, 59, 59);

  // Total transactions provider du mois
  const totalGross = await db.select({ total: sql`SUM(amount)` })
    .from(orders)
    .where(and(
      gte(orders.paidAt, monthStart),
      lte(orders.paidAt, monthEnd),
      eq(orders.status, "paid"),
    ));

  // Total reçu sur compte (à exporter manuellement depuis Wave Business + Orange Money B2B + Free Money portal)
  const receivedFromImport = importMonthlyReceived(yearMonth);

  // Frais théoriques par provider
  const theoreticalFees = {
    wave: totalByProvider.wave * 0.01,
    orangeMoney: totalByProvider.orangeMoney * 0.025,
    freeMoney: totalByProvider.freeMoney * 0.018,
  };
  const expectedNet = totalGross[0].total
    - theoreticalFees.wave
    - theoreticalFees.orangeMoney
    - theoreticalFees.freeMoney;

  const diff = receivedFromImport - expectedNet;
  console.log({ totalGross, expectedNet, receivedFromImport, diff });

  if (Math.abs(diff) > 1000) {
    await sendAlert(`Écart caisse mensuel : ${diff} FCFA`);
  }
}

Étape 4 — Rapport de réconciliation

Pour la comptabilité, exportez un rapport mensuel structuré :

  • Nombre de transactions par provider
  • Montant brut total par provider
  • Frais opérateur appliqués (calculés vs réels)
  • Montant net reçu
  • Liste des écarts non résolus
  • Liste des remboursements émis
  • TVA à reverser (selon régime fiscal)

Format CSV ou Excel pour transmission au comptable. Idéalement automatisé : un cron mensuel produit le rapport et l’envoie par email au gérant et au comptable agréé.

Étape 5 — Gestion des écarts non résolus

Pour chaque écart, classifiez :

  • ME_ONLY (commande paid mais pas chez provider) : potentiellement faux positif. Reconvérifier via API. Si confirmé absent, repasser en « failed » et débloquer la commande.
  • PROVIDER_ONLY (transaction provider sans commande) : webhook perdu, ou transaction frauduleuse. Investiguer avec le ref client. Si légitime, créer la commande manuellement.
  • AMOUNT_DIFF : suspect, audit nécessaire. Souvent un bug d’arrondi côté frontend.
  • Refund_pending : remboursement émis mais non confirmé côté provider. Relancer.

Documentez la résolution de chaque écart dans une table d’audit. Ça vous protège en cas de contrôle fiscal ou litige client.

Étape 6 — Outils utiles

  • Dashboards providers : Wave Business, Orange Money B2B portal, Free Money marchand — pour vue de leur côté
  • Export CSV/Excel mensuels depuis ces dashboards
  • Tableur récap : Excel ou Google Sheets avec onglets par provider
  • Power BI / Metabase / Grafana pour visualiser les volumes et écarts
  • Comptable agréé local : indispensable pour valider le mapping fiscal

Adaptation Afrique de l’Ouest

  • TVA UEMOA : 18 % au Sénégal et CI, à reverser mensuellement (déclaration en ligne via DGID Sénégal, DGI CI)
  • IS sur bénéfice : variable selon pays, généralement 25-30 %
  • Devise unique : XOF (FCFA) pour la zone UEMOA, simplifie la conversion
  • Comptes bancaires vs mobile money : si vous recevez sur un compte mobile money business plutôt qu’un compte bancaire, demandez les relevés mensuels au format CSV
  • Audit fiscal : conservez 10 ans les justificatifs (Sénégal CGI), 7 ans en CI

Erreurs fréquentes

ErreurCauseSolution
Cron de polling pas lancésystemd timer mal configuréVérifier systemctl list-timers
Faux positifs ME_ONLYComparaison sur date locale vs UTCToujours UTC en backend, conversion à l’affichage
Frais réels ≠ théoriquesGrille tarifaire incomplèteDemander la grille exacte au commercial provider
TVA mal calculéeInclusion/exclusion des frais opérateurValidation comptable agréé
Écarts ignorésPas de processus formelPolitique : tout écart > 5000 FCFA traité sous 7 jours

Pour aller plus loin

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité