KES, TZS and UGX: Multi-Currency Ledgers and the FX Gap Eating Your Margin
A Nairobi SaaS company sells to customers in Kenya, Tanzania and Uganda. Prices are set in KES. Collections happen in KES, TZS and UGX. Costs are in USD. Somewhere in that pipeline, several percent of revenue disappears, and nobody can point at where.
I have debugged this exact situation more than once, and the answer is always the same two mistakes.
Mistake one: treating "the exchange rate" as a single number. There is no such thing in East Africa. There is a central bank indicative rate, an interbank rate, a commercial bank retail rate, a mobile money operator rate and a bureau rate, and on any given Tuesday they can differ by four percent.
Mistake two: storing money as a float in one currency. Every amount needs a currency stamped on it and every conversion needs the rate that was actually used, recorded at the moment it was used.
Minor units are not universal
The first bug is usually mechanical. Developers write amount * 100 and move on.
| Currency | ISO 4217 exponent | Practical reality |
|---|---|---|
| KES | 2 | Cents exist in ledgers; M-Pesa STK Push only accepts whole shillings |
| TZS | 2 | Senti are defunct; everything transacts in whole shillings |
| UGX | 0 | No subunit at all. Multiplying by 100 is a real bug |
| RWF | 0 | No subunit |
| ETB | 2 | Santim in active use |
| USD | 2 | Your cost currency |
Encode this once, in a table, and never hardcode 100 again:
from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass
EXPONENT = {"KES": 2, "TZS": 2, "UGX": 0, "RWF": 0, "ETB": 2, "USD": 2, "EUR": 2}
@dataclass(frozen=True)
class Money:
minor: int
currency: str
@classmethod
def parse(cls, value, currency: str) -> "Money":
e = EXPONENT[currency]
q = Decimal(1).scaleb(-e)
d = Decimal(str(value)).quantize(q, rounding=ROUND_HALF_UP)
return cls(int(d.scaleb(e)), currency)
def major(self) -> Decimal:
return Decimal(self.minor).scaleb(-EXPONENT[self.currency])
def __add__(self, other):
if self.currency != other.currency:
raise ValueError(f"cannot add {self.currency} to {other.currency}")
return Money(self.minor + other.minor, self.currency)
That __add__ guard is not pedantry. It is the single cheapest way to turn "our revenue number is wrong" into a stack trace in CI.
Splitting money without leaking it
Splitting a UGX 100,000 invoice three ways gives 33,333.33 each - except UGX has no subunit, so you get 33,333 three times and one shilling vanishes. Do it a thousand times a day and you have a permanently unbalanced ledger. Use largest-remainder allocation:
def allocate(m: Money, ratios: list[int]) -> list[Money]:
total_ratio = sum(ratios)
shares, remainder = [], m.minor
for r in ratios:
share = m.minor * r // total_ratio
shares.append(share)
remainder -= share
# distribute the leftover minor units, largest ratio first
order = sorted(range(len(ratios)), key=lambda i: -ratios[i])
for i in order[:remainder]:
shares[i] += 1
return [Money(s, m.currency) for s in shares]
assert sum(x.minor for x in allocate(Money(100000, "UGX"), [1, 1, 1])) == 100000
The FX gap, quantified
As of late July 2026 the market is roughly: USD/KES around 130, USD/TZS around 2,630, USD/UGX around 3,670. Those are mid-market numbers. What you actually transact at is different, and the difference is the gap that eats your margin.
| Layer | Typical spread vs mid-market | Who charges it |
|---|---|---|
| CBK / BoT / BoU indicative rate | Reference only, published once daily | Central bank |
| Interbank | 0.1 - 0.5% | Banks to each other |
| Commercial bank retail | 2 - 4% | Your bank on settlement |
| Mobile money cross-border | 3 - 6% | MNO plus corridor partner |
| PSP markup | 0.5 - 2.5% on top | Your payment provider |
Note also that the central bank rates are daily. There is no intraday feed, and Friday's rate covers the weekend. If you price a Saturday transaction off Friday's indicative rate and settle on Monday, you have taken two days of unhedged risk without deciding to.
Store the rate you got, not the rate you looked up
This is the core schema decision.
CREATE TABLE fx_rates (
id BIGSERIAL PRIMARY KEY,
base_ccy CHAR(3) NOT NULL,
quote_ccy CHAR(3) NOT NULL,
rate NUMERIC(20,10) NOT NULL, -- 1 base = rate quote
source TEXT NOT NULL, -- 'CBK_INDICATIVE','PROVIDER_QUOTE','BANK_CONTRACT'
source_ref TEXT, -- provider quote id, deal ticket
captured_at TIMESTAMPTZ NOT NULL,
valid_until TIMESTAMPTZ, -- quotes expire. indicative rates do not.
UNIQUE (base_ccy, quote_ccy, source, captured_at)
);
CREATE TABLE fx_conversions (
id BIGSERIAL PRIMARY KEY,
entry_group UUID NOT NULL,
from_minor BIGINT NOT NULL,
from_ccy CHAR(3) NOT NULL,
to_minor BIGINT NOT NULL,
to_ccy CHAR(3) NOT NULL,
rate_id BIGINT NOT NULL REFERENCES fx_rates(id),
effective_rate NUMERIC(20,10) NOT NULL, -- to_major / from_major, INCLUDING fees
fee_minor BIGINT NOT NULL DEFAULT 0,
fee_ccy CHAR(3) NOT NULL,
executed_at TIMESTAMPTZ NOT NULL
);
effective_rate is the field that finds the leak. Compare it against the reference rate for the same instant and you get your true all-in FX cost per corridor, per provider, per week. Most teams have never measured this and are shocked the first time they do.
SELECT date_trunc('week', c.executed_at) AS wk,
c.from_ccy, c.to_ccy,
count(*) AS conversions,
sum(c.from_minor) / 100.0 AS volume_major,
round(avg((c.effective_rate / r.rate - 1) * 100), 3) AS avg_slippage_pct
FROM fx_conversions c
JOIN fx_rates r ON r.id = c.rate_id
GROUP BY 1, 2, 3
ORDER BY 1 DESC, volume_major DESC;
Quote, lock, expire
Never convert at display time and hope the rate holds. Issue an explicit quote with a TTL, show the customer that quote, and honour it or refuse it.
// Quote is a first-class object with a hard expiry.
async function createQuote({ sellCcy, buyCcy, sellMinor }) {
const rate = await fx.reference(sellCcy, buyCcy); // mid-market
const spreadBps = spreadFor(sellCcy, buyCcy); // your margin, in basis points
const applied = rate * (1 - spreadBps / 10000);
const q = {
id: crypto.randomUUID(),
sellCcy, buyCcy, sellMinor,
buyMinor: toMinor(fromMinor(sellMinor, sellCcy) * applied, buyCcy),
referenceRate: rate,
appliedRate: applied,
expiresAt: new Date(Date.now() + 90_000), // 90 seconds. be honest about it.
};
await db.quotes.insert(q);
return q;
}
async function executeQuote(id) {
const q = await db.quotes.findById(id);
if (!q) throw new Error("unknown quote");
if (new Date() > q.expiresAt) throw new QuoteExpired(id);
// ... book the conversion against this exact quote id
}
Ninety seconds is a deliberate choice. Long enough for a customer to confirm, short enough that a shilling move does not become your loss. On volatile days I drop it to 45.
The accounting: realised versus unrealised
Once you hold balances in more than one currency you have an FX position whether you wanted one or not. The books need two accounts:
- Unrealised FX gain/loss - revaluation of balances you still hold at each period end.
- Realised FX gain/loss - the difference locked in when you actually convert.
A worked example. You invoice a Ugandan customer UGX 3,670,000 when the rate is 28.2 UGX per KES, so you book KES 130,142 of revenue. Three days later the payout lands and you convert at 28.9.
| Account | DR (KES) | CR (KES) |
|---|---|---|
| UGX receivable (at 28.2) | 130,142 | |
| Revenue | 130,142 | |
| KES bank (3,670,000 / 28.9) | 126,990 | |
| Realised FX loss | 3,152 | |
| UGX receivable | 130,142 |
That KES 3,152 is not a bug and it is not a rounding error. It is the cost of carrying a UGX position for three days, and once it is a named line in your P&L someone can decide whether to hedge it, price it in, or settle faster.
Settlement reality in the region
Some hard constraints worth designing around rather than fighting:
- M-Pesa Kenya settles KES only. M-Pesa Tanzania settles TZS only. There is no single wallet spanning both, and the two Daraja-equivalent APIs are different products.
- Uganda is a two-network problem: MTN MoMo and Airtel Money, with different APIs and different settlement cycles.
- Cross-border mobile money exists in these corridors but the rate is set by the operator, is not published as a feed, and moves without notice. Always capture the rate from the transaction response, never from your own assumption.
- PAPSS is genuinely changing this. Its Pesalink tie-up in Kenya now routes cross-border payments into Kenyan banks and mobile money in local currency, settling without a USD leg. Nineteen countries and 160-plus banks are connected. If your corridor is covered, ask your bank about it - removing the dollar leg removes two spreads.
Five rules I hold to
- Quote in the customer's currency, settle in yours, and record both legs. Never store a converted amount without its source amount and rate.
- Never convert twice. KES to USD to UGX costs two spreads. Find a direct corridor or hold a UGX balance.
- Snapshot rates daily and keep them forever. Restating history needs the rate that applied then, not the one that applies now.
- Alert on slippage, not just on failures. A provider that quietly widens its spread by 80 basis points costs you more than an outage and triggers no alarm.
- Hold a working balance in each currency you collect in. Converting on every transaction is the most expensive possible pattern; converting on a schedule, in size, is materially cheaper.
Multi-currency is not a formatting problem. It is a ledger problem with a formatting symptom. Get
Money,fx_ratesandfx_conversionsright on day one and the reporting, the hedging and the pricing conversations all become possible. Get them wrong and you will be arguing about spreadsheets for years.