Skip to content
All work
Live in production
Fintech · Central Africa
2026

QuataPay

Mobile-money & card payments platform

A full payments platform for Cameroon — wallets, agent cash-in/cash-out, merchant POS, bill pay, remittances and virtual cards — on live MTN MoMo and Orange Money rails.

Visit live site
quatapay.com

Role

Sole developer

Client

QUATA Digital Enterprise

Status

Live in production

Year

2026

1,390

API endpoints

272

DB tables

687k

Lines of code

303

Migrations

3

Flutter apps

80%

Coverage gate

The hardest problem

Making every money-moving endpoint safe to retry, on a network where retries are the normal case rather than the edge case. A request that times out on a Cameroonian mobile network has, from the phone's point of view, an unknown outcome. Retry and you may move real money twice. Don't retry and a user's cash-out silently fails.

What I did about it

01

Idempotency became a structural property of the data model rather than a check in each handler. Balances are not a mutable column — they are derived from an append-only ledger_entries table.

02

Every entry carries an idempotency key that is unique at the database level, so a replay hits a uniqueness constraint and returns the original result. Postgres enforces the guarantee, not a remembered if already_processed branch.

03

External rail calls are wrapped identically via processed_webhook_events, retry_jobs and webhook_deliveries, so a duplicate MoMo callback is absorbed rather than double-credited.

04

Money is held in integer minor units throughout, with floats banned past the display layer, so replay and reconciliation compare exact values.

What I rejected, and why

Optimistic locking on a balance column would have been simpler to write and impossible to audit. It makes the current balance authoritative and the history advisory — exactly backwards for a payments system. Every balance read is now a SUM, mitigated with a cached value plus periodic invariant checks that re-derive and compare. Worth paying.

What I’d change

The coverage gate is set at 80% but the actual measured number was never recorded, so I can say the gate passes and not what it passes at. I would wire coverage reporting into CI output from day one — a gate you cannot read is a gate you eventually stop trusting.

Built with

FastAPIPython 3.12SQLAlchemy 2 asyncPostgreSQLRedisCeleryNext.js 16React 19Tailwind 4FlutterDocker

Next case study

Abaqwa

Six booking verticals, one platform