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 :
- Vos commandes internes : ce que votre application a enregistré (statut « paid »)
- Les API providers : ce que Wave, OM, Free Money confirment côté serveur
- 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 :
- Polling proactif (toutes les 10-30 min) : interroger l’API de chaque provider pour les commandes « pending » anciennes et synchroniser leur statut
- 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
- 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
| Erreur | Cause | Solution |
|---|---|---|
| Cron de polling pas lancé | systemd timer mal configuré | Vérifier systemctl list-timers |
| Faux positifs ME_ONLY | Comparaison sur date locale vs UTC | Toujours UTC en backend, conversion à l’affichage |
| Frais réels ≠ théoriques | Grille tarifaire incomplète | Demander la grille exacte au commercial provider |
| TVA mal calculée | Inclusion/exclusion des frais opérateur | Validation comptable agréé |
| Écarts ignorés | Pas de processus formel | Politique : tout écart > 5000 FCFA traité sous 7 jours |