Skip to content
XGitHubEmail

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.

Nick· VP & Chief of Staff
Jul 29, 2026·9 min read
moneyrailscurrenciesfintechafricaledger

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:

  1. POST /quote signs a payload: user, pair, amount, held rate, nonce, expiry.
  2. POST /convert verifies 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

  1. Grep for Money.new and * 100 near wallet code. Kill the ones that assume subunit 100.
  2. List every currency string your API returns. Diff against Money::Currency codes.
  3. Put rates behind one service with reverse pairs and an audit row.
  4. 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.