E-commerce13 min read

Stripe Billing multi-currency FCFA + EUR + USD: implementation 2026

Mohamed Bah·Fondateur, Kolonell
June 2, 2026
Share:
Stripe Billing multi-currency FCFA + EUR + USD: implementation 2026

Stripe Billing multi-currency FCFA + EUR + USD: implementation 2026

E-commerce

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 subscription active.
  • Wave webhook charge.success each 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

PhaseEffortCost at 250K FCFA/day
Stripe Billing EUR/USD setup3 days750,000 FCFA
Wave Business + webhooks integration5 days1,250,000 FCFA
Stripe ↔ Wave sync + SYSCOHADA invoicing6 days1,500,000 FCFA
Tests + edge cases + monitoring4 days1,000,000 FCFA
Total18 days4,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.

Tags:#Stripe Billing#Wave#FCFA#SaaS#SYSCOHADA#multi-currency
Share:

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.