Stripe Billing multi-currency FCFA + EUR + USD: the 2026 reality
The problem: Stripe (Standard and Express) does not natively support XOF (West African CFA Franc) for subscription billing. The "XOF" currency exists in the Stripe API but Stripe has no acquirer accepting XOF for card payments. Consequence: a Senegal SaaS that wants to bill local clients in FCFA + European clients in EUR + US diaspora in USD must build a hybrid architecture.
I designed and implemented this architecture for 4 Senegal SaaS (1 health-tech, 1 B2B fintech, 1 ed-tech, 1 marketplace). Here is the full method with Node.js code, FX management, and SYSCOHADA compliance.
H2: Battle-tested multi-currency architecture
Principle: Stripe handles EUR + USD natively (international cards, diaspora). For local Senegal clients in FCFA, we combine Wave Business API (recurring FCFA mandate) + custom SYSCOHADA-compliant invoicing. Stripe is the "source of truth" for subscriptions and invoicing logic, Wave is the FCFA payment executor.
`
EUR client ──EUR/USD──▶ Stripe Billing ──Credit card──▶ Stripe acquirer
│
▼ source of truth
FCFA client ──FCFA──▶ Wave Business ──USSD/QR──▶ Wave wallet
│
▼
Invoice PDF SYSCOHADA
`
H2: Stripe Billing setup for EUR + USD
`typescript
// stripe-billing-setup.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-04-15.basil',
});
// 1. Create a Product
const product = await stripe.products.create({
name: 'SaaS Pro',
description: 'Kolonell SaaS Pro Subscription',
});
// 2. Create 2 Prices: EUR + USD
const priceEUR = await stripe.prices.create({
product: product.id,
unit_amount: 4900, // 49.00 EUR
currency: 'eur',
recurring: { interval: 'month' },
lookup_key: 'pro_eur_monthly',
});
const priceUSD = await stripe.prices.create({
product: product.id,
unit_amount: 5400, // 54.00 USD
currency: 'usd',
recurring: { interval: 'month' },
lookup_key: 'pro_usd_monthly',
});
`
Tip 1: use lookup_key to avoid hardcoding Price IDs in front-end. Fetch via stripe.prices.list({ lookup_keys: ['pro_eur_monthly'] }).
Tip 2: the official EUR → FCFA conversion rate is 655.957 (BCEAO fixed parity Euro-CFA UEMOA). Always show FCFA equivalent in front even for EUR payment (clarity for local managers who think in FCFA).
H2: Wave Business for recurring FCFA
Wave Business API (v3, 2026) supports recurring mandate via webhook workflow. Setup:
`typescript
// wave-subscription.ts
import axios from 'axios';
const WAVE_API = 'https://api.wave.com/v1';
const WAVE_TOKEN = process.env.WAVE_BUSINESS_TOKEN!;
interface WaveMandate {
customerId: string;
amountFCFA: number;
frequency: 'monthly' | 'weekly';
startDate: string;
}
async function createWaveMandate(mandate: WaveMandate) {
const res = await axios.post(
${WAVE_API}/mandates,
{
receive_amount: mandate.amountFCFA,
currency: 'XOF',
customer_id: mandate.customerId,
frequency: mandate.frequency,
first_charge_date: mandate.startDate,
idempotency_key: mandate-${mandate.customerId}-${mandate.startDate},
},
{
headers: {
Authorization: Bearer ${WAVE_TOKEN},
'Content-Type': 'application/json',
},
},
);
return res.data;
}
`
FCFA client workflow:
- Client picks "Pro 32,000 FCFA / month" in front.
- Backend calls
createWaveMandate. - Client receives Wave SMS to confirm mandate (tap + PIN).
- Wave webhook
mandate.activated→ our backend marks subscriptionactive. - Wave webhook
charge.successeach month → backend updates invoice + matching Stripe Subscription.
Important: create a "fake" Stripe Subscription with payment_behavior: 'pending_if_incomplete' for FCFA clients, so the source of truth is unique. Real payment goes through Wave, but Stripe keeps status and invoice number.
H2: Stripe ↔ Wave sync (the real challenge)
`typescript
// sync-wave-to-stripe.ts
import { stripe } from './stripe-client';
interface WaveChargeWebhook {
event: 'charge.success' | 'charge.failed';
mandate_id: string;
amount_fcfa: number;
timestamp: string;
}
async function handleWaveCharge(webhook: WaveChargeWebhook) {
// 1. Find matching Stripe customer
const customer = await findCustomerByWaveMandate(webhook.mandate_id);
if (!customer) return;
// 2. Get equivalent FCFA Stripe Subscription
const subscriptions = await stripe.subscriptions.list({
customer: customer.stripeCustomerId,
status: 'active',
});
const fcfaSub = subscriptions.data.find((s) => s.metadata.currency_real === 'XOF');
if (!fcfaSub) return;
if (webhook.event === 'charge.success') {
// 3. Create EUR-equivalent Stripe Invoice (for reporting)
const amountEUR = Math.round((webhook.amount_fcfa / 655.957) * 100); // cents
Need a professional website?
Kolonell builds websites that attract clients, optimized for the Sénégalese market. Free quote in 2 minutes.
const invoice = await stripe.invoices.create({
customer: customer.stripeCustomerId,
subscription: fcfaSub.id,
auto_advance: false,
collection_method: 'send_invoice',
metadata: {
currency_real: 'XOF',
amount_real_fcfa: String(webhook.amount_fcfa),
wave_charge_id: webhook.mandate_id,
},
});
await stripe.invoices.pay(invoice.id, { paid_out_of_band: true });
} else {
// 4. Mark subscription past_due
await stripe.subscriptions.update(fcfaSub.id, {
metadata: { wave_status: 'past_due', last_attempt: webhook.timestamp },
});
}
}
`
Key: paid_out_of_band: true tells Stripe the payment was received outside Stripe (via Wave). Invoice is marked paid, revenue shows in Stripe Reports with metadata to reconstruct real FCFA mapping.
H2: SYSCOHADA compliance — Senegal invoicing
SYSCOHADA (OHADA Accounting System, v2 revised 2018, applied in Senegal) requires:
- Continuous numbering of invoices (no gap).
- Distinct VAT mention 18% (except exemptions art. 354 CGI Senegal).
- Issuer NINEA + RCCM mention.
- Currency: stateable in FCFA (XOF), with optional secondary EUR/USD label.
- 10-year archiving (paper or compliant electronic).
Implementation: generate custom PDF with pdfkit (already in Kolonell stack).
`typescript
// invoice-syscohada.ts
import PDFDocument from 'pdfkit';
interface InvoiceData {
invoiceNumber: string; // e.g. 'KOL-2026-00427'
emitterNINEA: string;
emitterRCCM: string;
customerName: string;
customerNINEA?: string; // optional for B2C
items: { description: string; quantity: number; unitPriceHT: number }[];
currency: 'XOF' | 'EUR' | 'USD';
tvaRate: number; // 0.18 default
}
function generateSyscohadaInvoice(data: InvoiceData): Buffer {
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const buffers: Buffer[] = [];
doc.on('data', (b) => buffers.push(b));
// Compliant header
doc.fontSize(20).text('INVOICE', { align: 'right' });
doc.fontSize(10).text(No. ${data.invoiceNumber}, { align: 'right' });
doc.text(NINEA: ${data.emitterNINEA});
doc.text(RCCM: ${data.emitterRCCM});
// HT, VAT, TTC calc
const totalHT = data.items.reduce((s, it) => s + it.quantity * it.unitPriceHT, 0);
const tva = totalHT * data.tvaRate;
const totalTTC = totalHT + tva;
doc.moveDown().text(Total HT: ${formatCurrency(totalHT, data.currency)});
doc.text(VAT ${(data.tvaRate * 100).toFixed(0)}%: ${formatCurrency(tva, data.currency)});
doc.fontSize(14).text(Total TTC: ${formatCurrency(totalTTC, data.currency)}, {
underline: true,
});
if (data.currency !== 'XOF') {
const equivXOF = data.currency === 'EUR' ? totalTTC * 655.957 : totalTTC * 605; // indicative USD rate
doc.fontSize(10).text((Equivalent: ${formatCurrency(equivXOF, 'XOF')}), { align: 'right' });
}
doc.end();
return Buffer.concat(buffers);
}
function formatCurrency(amount: number, currency: 'XOF' | 'EUR' | 'USD'): string {
return new Intl.NumberFormat('fr-SN', { style: 'currency', currency }).format(amount);
}
`
H2: EUR/USD/FCFA rate management
Fixed parity: 1 EUR = 655.957 XOF (BCEAO, French Treasury parity, does not move).
Floating USD rate: must be refreshed daily. Free sources:
- exchangerate-api.com (free up to 1,500 req/month).
- fixer.io (free 100 req/month).
- BCEAO API (official, slow but sovereign).
24h Redis cache implementation:
`typescript
// fx-cache.ts
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
async function getUsdToXof(): Promise
const cached = await redis.get('fx:usd:xof');
if (cached) return parseFloat(cached);
const res = await fetch('https://api.exchangerate-api.com/v4/latest/USD');
const data = await res.json();
const rate = data.rates.XOF; // e.g. 605.32
await redis.set('fx:usd:xof', String(rate), 'EX', 86400); // 24h
return rate;
}
`
Business rule: apply +1.5% rate margin to absorb intra-month volatility (otherwise loss on bearish USD months).
H2: Edge cases observed in production
- Wave client with insufficient balance: 3 attempts at D, D+1, D+2 (configure in Wave Business). If full failure: email + subscription suspension.
- Stripe SCA Europe for EU cards: implement mandatory 3D Secure 2 (
payment_intent.confirmation_method = 'automatic'+use_stripe_sdk: true). - Client requests EUR invoice while paying in FCFA: generate 2 PDFs (FCFA for Senegal accountant, EUR for parent reporting).
- Wave refund: requires manual intervention (no instant refund API). Plan 48h client delay.
- Missed Wave webhook: implement idempotency + retry queue (BullMQ + Redis). Wave sends 3 automatic retries over 24h.
H2: Implementation cost
| Phase | Effort | Cost at 250K FCFA/day |
|---|---|---|
| Stripe Billing EUR/USD setup | 3 days | 750,000 FCFA |
| Wave Business + webhooks integration | 5 days | 1,250,000 FCFA |
| Stripe ↔ Wave sync + SYSCOHADA invoicing | 6 days | 1,500,000 FCFA |
| Tests + edge cases + monitoring | 4 days | 1,000,000 FCFA |
| Total | 18 days | 4,500,000 FCFA (~6,850 EUR) |
FAQ
Why doesn't Stripe support FCFA in card present?
Stripe has no banking acquirer in UEMOA zone. Theoretically possible via Stripe Atlas + local banking arrangement, but not operational in 2026. Wave workaround is the only scalable path.
Can we use PayDunya or CinetPay instead of Wave?
Yes technically. PayDunya supports recurring since 2024. Drawbacks: 4% fees (vs 1% Wave), 12% mandate failure rate (vs 2% Wave), less smooth UX. For mass-B2C SaaS in Senegal, Wave remains #1.
Which EUR/USD rate to use for accounting reporting?
For SYSCOHADA, use invoice issuance day rate (BCEAO publication). For internal Stripe Reports, Stripe rate (close to market). Difference < 0.5% in practice.
How to handle VAT exemption (export outside UEMOA)?
For diaspora clients outside UEMOA (France, EU, US): exported digital services, Senegal VAT exemption (art. 354 CGI) if billed in foreign currency. Mention "Service export - VAT exempt art. 354 CGI" on invoice.
SCA Europe: do all EUR payments need 3DS?
Yes since Sept. 2019 (PSD2). Stripe handles 3DS 2 automatically if payment_intent.confirmation_method = 'automatic'. Senegal-issuer Wave/Orange 3DS failure rate: ~8% (vs 2% EU cards), plan Wave fallback.
Let's talk about your case
If you are building a multi-currency Senegal SaaS and want to implement a Stripe Billing + Wave + SYSCOHADA-compliant stack, we design the architecture. WhatsApp +221 77 596 93 33.
Mohamed Bah
Fondateur, Kolonell
Passionate about digital and entrepreneurship in Africa, Mohamed has been helping Sénégalese businesses with their digital transformation since 2020. Founder of Kolonell, he believes every SME deserves a professional and accessible online présence.

