</>CodeWithKarani

KES, TZS and UGX: Multi-Currency Ledgers and the FX Gap Eating Your Margin

Karani GeoffreyKarani Geoffrey8 min read

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.

CurrencyISO 4217 exponentPractical reality
KES2Cents exist in ledgers; M-Pesa STK Push only accepts whole shillings
TZS2Senti are defunct; everything transacts in whole shillings
UGX0No subunit at all. Multiplying by 100 is a real bug
RWF0No subunit
ETB2Santim in active use
USD2Your 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.

LayerTypical spread vs mid-marketWho charges it
CBK / BoT / BoU indicative rateReference only, published once dailyCentral bank
Interbank0.1 - 0.5%Banks to each other
Commercial bank retail2 - 4%Your bank on settlement
Mobile money cross-border3 - 6%MNO plus corridor partner
PSP markup0.5 - 2.5% on topYour 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.

AccountDR (KES)CR (KES)
UGX receivable (at 28.2)130,142
Revenue130,142
KES bank (3,670,000 / 28.9)126,990
Realised FX loss3,152
UGX receivable130,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

  1. Quote in the customer's currency, settle in yours, and record both legs. Never store a converted amount without its source amount and rate.
  2. Never convert twice. KES to USD to UGX costs two spreads. Find a direct corridor or hold a UGX balance.
  3. Snapshot rates daily and keep them forever. Restating history needs the rate that applied then, not the one that applies now.
  4. 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.
  5. 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_rates and fx_conversions right 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.

#fx#multi-currency#east-africa#ledger#fintech
Keep reading

Related articles