technology
· · 5 min readWhy We Built a Double-Entry Ledger for African Payments
Most African payment gateways treat transactions as status updates. We built Nyuchi Pay on a proper double-entry accounting engine with separate fiat and token schemas.
By Bryan Fawcett
Most payment integrations in African markets work like this: you call the gateway, you get a status back, you write a row to a transactions table with a status column. If the webhook fires twice, you hope your code handles it. If the amounts don't reconcile at month-end, you open a spreadsheet and start guessing.
We decided early on that Nyuchi Pay would not work this way.
The problem with single-entry transaction tables
When you record a payment as a single row — "User A paid $10, status: completed" — you've captured an event, but you haven't captured where the money went. Was it in the merchant's float? Is the gateway's settlement account holding it? Has the fee been separated? If a refund happens three weeks later, which balance does it come from?
Single-entry systems answer these questions with application logic — if-statements, cron jobs, manual reconciliation. That works until it doesn't. And in markets where mobile money providers have different settlement timings, where EcoCash and Innbucks have different confirmation flows, where connectivity drops mid-transaction — it stops working faster than you'd expect.
Double-entry as the foundation
Every money movement in Nyuchi Pay is a journal entry with at least two postings that sum to zero. A customer pays a merchant $10 via EcoCash:
Journal Entry: payment_001
├── DEBIT customer_wallet $10.00
├── CREDIT merchant_wallet $9.50
└── CREDIT fee_revenue $0.50
SUM = $0.00
This isn't novel — it's how banks and accounting systems have worked for centuries. What's novel is applying it to African payment gateway integrations, where the norm is flat transaction tables with a status enum.
The benefit is immediate: every balance is derivable from the posting history. There is no balance column that can drift out of sync. When a gateway webhook fires twice with the same payment confirmation, the idempotency key on the journal entry deduplicates it at the database level. When a settlement hits two days later, it's another journal entry that moves funds from gateway_settlement to merchant_available — the audit trail is complete.
The ledger engine is live in our pay database today: ledger.account, ledger.journal_entry, ledger.posting, and ledger.balance_snapshot tables, all with RLS defaulted to deny and a hash-chained audit.event log tracking every mutation.
Two worlds, one engine
Nyuchi Pay will handle two fundamentally different kinds of money: fiat (Zimbabwe dollars via Paynow, US dollars via ContiPay's Visa/Mastercard rails, mobile money across EcoCash, Innbucks, OneMoney) and Mukoko platform tokens (MIT, MXT, NST, NHC on the Honeycomb chain).
The plan is to house these in separate schemas — fiat.* and token.* — within the same database, sharing the same underlying ledger engine: same posting mechanics, same sum-to-zero constraint, same idempotency pattern. But the settlement mechanics will be completely different. Fiat postings reconcile against provider webhook confirmations. Token postings use a custodial model: internal transfers are pure ledger entries, and on-chain interaction only happens at deposit and withdrawal boundaries.
This dual-schema approach is a deliberate architectural choice. We prototyped a single unified schema first and it created confusion — the lifecycle of an EcoCash payment has nothing in common with the lifecycle of an MXT transfer. Separate schemas, shared engine.
The IP-whitelisting problem
Here's a problem every Zimbabwean developer has hit: Paynow whitelists by IP address. If your application is deployed on Vercel, Cloudflare Workers, or any serverless platform, you don't have a static IP. You can't integrate with Paynow.
The standard workaround is to spin up a VPS in Zimbabwe, get a static IP whitelisted, and proxy your requests through it. Every developer does this independently. Every developer maintains their own proxy.
We're formalising this into a shared service. The Nyuchi Relay will sit on a Zimbabwean VPS with a whitelisted IP, validate your API key, rate-limit per key, log every request, and forward to Paynow (and later ContiPay, Pesepay, and others). The gateway schema is already built — gateway.usage_log (monthly-partitioned), gateway.rate_limit_bucket, gateway.abuse_signal, and gateway.outage_event — ready for when the relay goes live.
What we've learned so far
Building payment infrastructure for Zimbabwe is teaching us things that aren't in the Stripe engineering blog:
Idempotency is not optional. In markets where mobile money confirmations arrive asynchronously and network interruptions cause retries, every money-moving endpoint needs a client-supplied idempotency key. We enforce this at the database level with a unique constraint, not in application code.
Settlement timing varies wildly. EcoCash confirms in seconds. Paynow batch-settles. ContiPay's Visa flow goes through 3DS redirect. A single transaction model cannot represent all of these gracefully. The journal entry model handles it naturally — each state transition is a new entry.
Provider credentials are the most sensitive data you hold. Gateway API keys and integration secrets are stored in supabase_vault, never logged, and access is audited via the hash-chained audit.event table. Row-level security defaults to deny on every table.
Bryan Fawcett is the founder and CEO of Nyuchi Africa, building frontier infrastructure for African markets.