WalleoPay WalleoPay
EN

The CFA franc has no cents, and that changes everything

1,500 francs is stored as 1500, never 150000: the CFA franc has a decimal exponent of zero. That single detail invalidates most habits inherited from two-decimal currencies.

L'équipe WalleoPay September 19, 2026 6 min read 1 lectures

A developer who has already integrated a payment in euros or dollars arrives with a reflex: "amounts are handled in cents". The reflex is a good one. Applied to the CFA franc, it produces invoices a hundred times too high.

The decimal exponent, in one line

Every currency has an exponent: the number of decimals in its sub-unit. The euro and the dollar are 2 - one euro is a hundred cents. The CFA franc is 0. It has no sub-unit. There is no such thing as a centime of a CFA franc.

The direct consequence: 1500 XAF is stored as 1500. Not 150000. The smallest unit of the CFA franc is the franc itself.

The same holds for the West African CFA franc. Both are exponent zero.

Why we still store integers

If the franc has no sub-unit, why talk about "minor units" at all? Because the rule that matters is not "multiply by a hundred", it is never represent money with a floating-point number.

A float does not store decimals exactly. In PHP as anywhere else, 0.1 + 0.2 is not 0.3. On a single transaction the gap is invisible; on a ledger summed thousands of times, it becomes a balance that never quite lands and that nobody can explain any more.

Hence the principle: amounts travel as integers everywhere, in the smallest unit of their currency. For the CFA franc that means whole francs. For the euro, cents. Conversion happens in exactly two places: on the way in, when a human types a value, and on the way out, when you display it or hand it to a third-party API.

Money::toMinor('1500', 'XAF');   // 1500   - exponent 0
Money::toMinor('15.50', 'EUR');  // 1550   - exponent 2
Money::toDecimal(1500, 'XAF');   // "1500"
Money::toDecimal(1550, 'EUR');   // "15.50"
Money::format(1500, 'XAF');      // "1 500 FCFA"

Note that the conversion to a decimal value exists for a precise reason: some operator APIs expect a formatted amount rather than an integer. You then have to respect their format exactly - and above all not manufacture 1500.00 for a currency that has no decimals.

Bounds, and where to check them

Amounts are bounded on both sides: from 100 FCFA to 1,000,000 FCFA per payment. Those bounds are worth enforcing in your own form, at the moment a human types a value, rather than discovering them through a rejected API call. A customer who reads "minimum 100 FCFA" under your own field understands it; the same customer stuck on a checkout page that fails without explanation does not.

The integer rule deserves the same treatment. Validate that what leaves your form is a whole number of francs before it ever reaches a payment: a price field that quietly accepts 15 000,50 is a bug you have already shipped.

The euro sandbox trap

A concrete case that bites regularly: MTN's sandbox only accepts the euro as a currency, whatever the real currency of the transaction, while production works in CFA francs.

If your code derives the transmitted amount from the currency the operator declares, you will never exercise the real path - and on the day you go live, the conversion changes exponent under your feet. The fix is simple: let the currency used to format the amount come from the driver's configuration, never from a hardcoded value, and keep the calculation identical in both modes.

Rounding, and which way it falls

A percentage commission almost always produces a fractional result. On 1,500 francs at 3%, the commission is exactly 45 francs; on 1,250 francs it is 37.5. The CFA franc knows nothing about half francs.

So you have to decide, once and for all, which way rounding falls - and write that choice down. Here, the commission is rounded up to the next franc, and the fixed part is added afterwards:

Money::fee(1250, 3.0, 0);   // ceil(37.5) = 38

Rounding up is not greed, it is predictability: the advertised rate is a guaranteed floor, and no merchant ever ends up paying more than what they read, nor the platform collecting less than what it announced. The important thing is not the direction of the rounding, it is that it is single, applied in one place, and never recomputed elsewhere. Two places rounding the same amount is an accounting discrepancy waiting to happen.

Corollary: a commission is computed once, when the payment is created, and stored with it. You do not recompute a commission at display time - otherwise a change of pricing rewrites history.

Display

An amount that reads naturally in Cameroon is written 1 500 FCFA: a thousands separator, no decimals, symbol after the number. The separator is a space, not a comma or a dot - 1,500 reads as "one thousand five hundred" to an English speaker and as "one point five" to a French one.

And that formatting belongs to the display layer alone. A formatted string must never flow back into a calculation: the day somebody tries to add up "1 500 FCFA", they get 1.

The mistakes that actually happen

Multiplying by 100 out of habit. A 25,000-franc invoice becomes 2,500,000. The customer refuses, and rightly so.

Dividing by 100 on display. The mirror image: 25,000 francs shown as "250 FCFA". That one goes unnoticed far longer, because nobody complains about a price that is too low.

Sending 1500.00 to an API in XAF. Depending on the implementation at the other end, the amount is rejected, truncated or reinterpreted. None of those three outcomes is desirable.

Using a float in the database. A decimal column copes; a float column eventually produces balances that will not reconcile.

Adding currencies together. A balance only means something per currency. Summing XAF and EUR in the same column means nothing, even if both are integers.

The checklist

  1. Store every amount as an integer, in the smallest unit of its currency.
  2. Carry the currency with the amount, always - a lone integer means nothing.
  3. Convert only at the boundaries: human input and display.
  4. Check the exponent rather than assume it: 0 for XAF and XOF, 2 for the euro and the dollar.
  5. Round in one place, in a direction you decided and documented.
  6. Store the computed commission with the payment, never recompute it after the fact.
  7. Never let a formatted string fall back into a calculation.

Those seven rules fit in a hundred lines of code. They rule out the most expensive class of bug there is: the one you find months later, while trying to explain a balance.

Someone else might need this
Sujets xaf amounts rounding api

Vous voulez essayer par vous-même ?

Ouvrez un compte WalleoPay : vos clés de test sont disponibles immédiatement, et le code écrit en test est celui qui encaissera en réel.