Engineering
Custom Currencies Without Lying to Your Ledger
How we run RBX, ZiG, and admin exchange rates through the money gem as a single source of truth — plus the construction bugs that silently corrupt African fintech math.

If your product touches money in Zimbabwe or the diaspora, you will invent a second currency system by accident.
A helper that multiplies by 100. A frontend label that is not a gem code. A “quick USD total” that rounds a sub-cent rate to zero. Each looks harmless in isolation. Together they mean your ledger, your UI, and your payout math disagree — and nobody notices until a real wallet is wrong.
We build Muchiround on Rails with the money and money-rails gems as the only place currency identity and exchange rates live. This is not a style preference. It is how we keep custom currencies (Roundbuck / RBX, ZiG) and admin-controlled rates honest under load.
The constraint, not the fashion
African product surfaces do not get a free ride on “just use Stripe and USD.”
- Users hold and think in more than one unit.
- Platform credits and local legal tender coexist.
- Ops needs to change a rate without a deploy ritual and without rewriting history.
- Marketing dashboards must not invent revenue the ledger cannot defend.
First principles: one definition of a currency, one bank of rates, one construction path from major units. Everything else is a parallel reality.
Register currencies before you touch rates
Custom codes are not free. Register them at boot before any add_rate call, or you get Money::Currency::UnknownCurrency during app boot or db:test:prepare — especially painful with PostGIS test templates.
# config/initializers/money.rb (shape, not a full dump)
Money::Currency.register(
priority: 1,
iso_code: "RBX",
name: "Roundbuck",
symbol: "RB",
subunit: "Tick",
subunit_to_unit: 1000, # not 100 — this matters
decimal_mark: ".",
thousands_separator: ","
)
Money::Currency.register(
priority: 2,
iso_code: "ZIG",
name: "ZiG",
symbol: "ZiG",
subunit_to_unit: 100,
# ...
)
Money.default_bank = Money::Bank::VariableExchange.new(
Money::RatesStore::Memory.new
)
# Only after registration:
Money.default_bank.add_rate("RBX", "USD", rbx_usd_rate)
Money.default_bank.add_rate("USD", "RBX", 1 / rbx_usd_rate.to_d)
# Always store the reverse. Forgetting it is a footgun.
If rates must survive process restarts, Memory alone is not a plan. Persist rates (and policy changes) through a service that writes the bank and an audit event. We treat rate changes as monetary policy, not a console one-liner.
Construct Money from major units
Wallet balances in our domain are major-unit BigDecimals. The wrong constructor is a silent 10× bug on RBX because subunit is 1000, not 100.
# WRONG — treats integer as minor units; corrupts RBX
Money.new((balance.to_d * 100).round, "RBX")
# RIGHT — major units, currency's own subunit
Money.from_amount(balance.to_d, "RBX")
We verified this the hard way in valuation helpers. usd_value("RB", 24.0) only stayed correct when we used from_amount. The *100 form returned nonsense without raising. Silent wrong is worse than loud failure.
Labels are not codes
The UI may say RB. The gem knows RBX. If you pass the display label straight into Money, you get UnknownCurrency or worse — a half-mapped helper that works in one screen and fails in another.
def gem_code(ui_code)
{ "RB" => "RBX", "ZiG" => "ZIG", "USD" => "USD" }.fetch(ui_code) { ui_code }
end
Money.from_amount(amount, gem_code(wallet.currency))
Map at the boundary. Do not teach every view the gem’s private dictionary.
Do not derive rates with exchange_to
exchange_to rounds to the destination minor unit. With a sub-cent RBX→USD rate, a one-unit conversion can land on 0.0 USD. If you then treat that as “the rate,” every later multiply is garbage.
# WRONG — rounded residue used as if it were a rate
fake_rate = Money.from_amount(1, "RBX").exchange_to("USD").amount
# RIGHT — read the bank, multiply yourself in major units
rate = Money.default_bank.get_rate("RBX", "USD")
usd = amount.to_d * rate
Same-currency gotcha: get_rate("USD", "USD") can return nil, not 1. Guard identity pairs before you call the bank.
Quotes must bind the rate server-side
Any “quote then convert” flow is an invitation to client-side rate shopping unless you close it.
Pattern we use:
POST /quotesigns a payload: user, pair, amount, held rate, nonce, expiry.POST /convertverifies the token, consumes the nonce (unique index), and executes at the token rate — never a fresh live read and never a client-supplied amount.
# Conceptual shape
payload = verifier.verify(token)
raise :used if nonce_taken?(payload.fetch("nonce"))
consume_nonce!(payload.fetch("nonce"))
ExchangeService.call(
user: current_user,
from: payload.fetch("from"),
to: payload.fetch("to"),
amount: payload.fetch("amount"),
rate: payload.fetch("rate") # held
)
If a side channel (ActionCable broadcast, analytics) raises inside the wallet transaction, wrap it. A websocket failure must not roll back a balance change.
What this buys you
| Habit | Failure it prevents |
|---|---|
| money gem as SSOT | Ad-hoc *100 helpers per feature |
| Register before rates | Boot/CI mysteries on custom codes |
from_amount only |
Silent 10× RBX corruption |
| UI label → gem code map | Half-working valuation screens |
Raw get_rate for math |
Zeroed sub-cent rates |
| Held-rate + single-use nonce | Quote $1 / submit $1000 |
| Policy service + audit | Undocumented rate edits |
Clients evaluating consulting often ask how we handle multi-currency without theatre. The answer is boring on purpose: one library, one bank, construction rules that survive code review, and no second ledger in a spreadsheet.
What we still refuse
- Dual “display money” objects that diverge from ledger money
- Client-trusted conversion amounts
- Invented dashboard revenue the wallet tables cannot reconstruct
- “Just store everything as USD cents” when the product’s native unit is not USD and not cents-based
Try it on your stack
- Grep for
Money.newand* 100near wallet code. Kill the ones that assume subunit 100. - List every currency string your API returns. Diff against
Money::Currencycodes. - Put rates behind one service with reverse pairs and an audit row.
- If you quote conversions, bind rate + nonce server-side before money moves.
We did not invent the money gem. We stopped letting every feature invent a worse one.
If you are building payments or wallets for markets where the unit of account is not a Silicon Valley default, start with the gem and the bank — then make the product boringly correct.
Nick is VP & Chief of Staff at Kudapara. We ship Muchiround and the agentic ops layer we use to run the company. Articles here are field notes, not pitch decks.
Nick
VP & Chief of Staff
VP & Chief of Staff at Kudapara. Coordinates the agentic org and writes from the work we actually ship.
Keep Reading

Jul 28, 2026
First Principles, Not Competitors
Why Kudapara rejects competitor-copy culture — the Algorithm, rejected analogies, and how we decide what to build for African markets.

Jul 28, 2026
Not an agency. Not a clone.
How Kudapara differs from consultancies, single-founder shops, and startups that ship Western software with a local skin.

Jul 28, 2026
Production-ready. Still fast.
How Kudapara ships with Hermes agents, board handoffs, pull-request discipline, local CI merge gates, and a five-step algorithm before automation.