CuniApp manages rabbit farms — livestock, sales, revenue — and at some point every management app meets the same wall: getting paid. In Benin that means Mobile Money, and for Laravel that meant FedaPay. The integration took an afternoon to start and a week to trust. Here is what the code taught me.
The franc has no decimals
The single most important line in the whole integration is a
cast. The CFA franc (XOF) has no fractional units, so amounts
must travel as integers. FedaPay will not round for you — send
1500.50 and you have a bug that only shows up in
reconciliation, weeks later:
// ✅ Amount in smallest unit (XOF has no decimals)
$amount = (int) round($transaction->amount);
Rule of thumb I now apply everywhere: convert to the smallest unit at the boundary, immediately, and never let a float near money again.
Keys live in three places
Local dev wants .env. Production wants config
cache. The farm owner wants a settings screen so they never
touch either. Rather than picking one, the service resolves in
order:
$this->publicKey = env('FEDAPAY_PUBLIC_KEY')
?? config('services.fedapay.public_key')
?? Setting::get('fedapay_public_key');
Environment first, config second, database settings last. Each layer overrides nothing silently — the first non-empty value wins, and the resolution is logged so you can always see which layer answered.
Sandbox first, always
The constructor picks its base URL from the environment, defaulting to sandbox. Production is opt-in, never the default:
$this->baseUrl = $this->environment === 'production'
? 'https://api.fedapay.com'
: 'https://sandbox-api.fedapay.com';
I have seen too many test charges hit real wallets because production was the default. Default to the sandbox; make production a deliberate act.
Log the handshake, not the secrets
On boot the service logs its environment, base URL, and only whether a secret exists — never the secret itself. When a payment fails at 11pm, the first question is always "which environment and which keys", and the answer should already be in the logs.
Notify on initiation, verify on callback
Starting a payment fires a
PaymentInitiatedNotification so the user sees
progress instantly, but nothing is marked paid until the
provider callback is verified server-side. Initiation is UX;
verification is accounting. Never confuse the two.
Takeaway
Payment integrations fail at boundaries: float-to-int, test-to-live, started-to-verified. Make each boundary explicit — cast early, sandbox by default, verify on callback — and mobile money becomes boring infrastructure instead of midnight debugging.