Engineering
One Fiat Process Path
How we stopped running two payment stacks — Pay processors for external fiat, money-gem for spendable balances, and the security gates that make that split honest.

African products rarely die from a missing checkout button. They die from two different stories about the same charge.
Story A lives in the PSP adapter: initialize, redirect, webhook, mark paid. Story B lives in the wallet: credit, convert, purpose, ledger. When those stories diverge, you get double credits, silent FX failures after a green card charge, and recon that only the person who wrote the adapter can run.
On Muchiround we settled the argument in product, not in a refactor theater. External fiat has one process shape. Spendable balances have one truth. This is the field note from SPEC-005 and the security pass that followed.
If you care about currencies and rates first, read Custom Currencies Without Lying to Your Ledger — that is the wallet half. This article is the path money takes to get there.
The dual-stack trap
The honest intermediate looked reasonable:
- Keep a battle-tested domain Paystack adapter for live top-ups.
- Adopt pay-rails/pay as substrate for “new methods later” (EcoCash, M-Pesa, Stripe, …).
- Leave money-gem alone as the wallet.
That hybrid doubles every future method. Every new rail needs “the Pay way” and “the old adapter way.” Webhook secrets resolve in two places. Metadata allowlists drift. Recon becomes folklore.
CEO call (rev3, same day as the hybrid): delete the dual process. All external fiat initialize / return / webhook / charge flows through Pay processors — including Paystack as a custom Pay processor. Domain purpose dispatch still owns why money moved. money-gem still owns what you can spend.
Browser / mobile
→ Payments::Rail (purpose + ExternalCharge)
→ Pay processor (Paystack custom / Fake / future)
→ signed webhook
→ purpose dispatch
→ money-gem wallet credit (+ optional FX to RBX)
Two conventions for the same fiat hop is not safety. It is two places to be wrong.
What stays outside Pay
We did not migrate the ledger into Pay.
| Concern | Owner |
|---|---|
| Card/bank hop, PSP charge id, webhook envelope | Pay processor |
Purpose (wallet_topup, rb_purchase, order shortfall, …) |
Domain rail |
| Spendable balances, multi-currency wallets | money-gem |
| Admin FX pairs (ZAR↔RBX, ZiG↔RBX, …) | money bank + monetary policy |
| Ad spend / RBX product units | Domain + money-gem |
Pay is not your product ledger. Treating it as one is how teams delete custom currencies by accident. money-gem remains SSOT for RBX, ZiG, and rates.
Shape of the domain rail
Names vary; the seams should not.
- Create a local charge with purpose, user, amount, currency, and server-owned metadata.
- Initialize via the processor — never let the client invent
purpose,user_id, orreference. - Return URL is UX only. Fulfillment is webhook-driven (or explicit verify that hits the same code path).
- Webhook verifies signature fail-closed, binds amount+currency to the local charge, then dispatches purpose once.
- Idempotency is a first-class column/key, not a log line.
Sketch (illustrative — not a dump of prod):
# Domain entry — purposes stay product language
module Payments
class Rail
def initialize_charge(user:, purpose:, amount_money:, metadata: {})
charge = ExternalCharge.create!(
user:, purpose:,
amount_cents: amount_money.cents,
currency: amount_money.currency.iso_code,
status: :pending,
idempotency_key: SecureRandom.uuid
)
processor = PayProcessor.for(:paystack) # or :fake in test
session = processor.checkout(
charge:,
# server wins — client metadata allowlisted only
metadata: server_metadata(charge).merge(allowlist(metadata))
)
charge.update!(processor_ref: session.reference)
session
end
end
end
Purpose dispatch is where African FX reality shows up. A successful ZAR Paystack charge for rb_purchase must credit fiat then convert through the money bank. Missing ZAR→RBX rates is not a “nice to have” — it is a green payment and a failed product outcome. We blocked live rb_purchase until those pairs were seeded and tested, not until a slide said “multi-currency.”
Security gates that pay rent
After the rail landed, security review was not optional polish. The findings map to every PSP integration we have seen:
Fail-closed signatures
Blank PAYSTACK_WEBHOOK_SECRET must reject in production/staging. Fail-open “so local works” becomes live unsigned credits the first time secrets miss a deploy.
def valid_signature?(payload, header)
secret = Pay::Paystack.webhook_secret
return false if secret.blank? && !Rails.env.local?
expected = OpenSSL::HMAC.hexdigest("SHA512", secret, payload)
ActiveSupport::SecurityUtils.secure_compare(expected, header.to_s)
end
One secret resolver. Two controllers both “kind of checking HMAC” is how prod drifts.
Server-wins metadata
Never let client metadata overwrite purpose, user_id, reference, or environment. Allowlist soft fields. For order purposes, assert order.buyer_id == current_user.id before initialize.
Amount + currency bind
Before mark_success / fulfill, require gateway amount and currency match the local ExternalCharge. A tampered or mismatched webhook must quarantine, not credit.
Fake hard-disable
A Fake processor is for CI. Production boot should refuse PAYMENTS_FAKE_ENABLED=true. “We only use Fake in staging” without a hard guard is a production incident waiting for a config typo.
Webhook event redaction
Do not persist full authorization/customer blobs in pay_webhooks.event logs. You do not need PAN-adjacent noise in the DB to recon a charge id.
Feature flags without theater
We ship flags that match operator reality:
PAYMENTS_PAYSTACK_ENABLED— live path.- Future rails behind their own flags (Stripe was explicitly not a day-one requirement).
- Commerce bulk email and growth mail stay off until their own smoke — SES sole provider does not mean “blast everything.”
Flags are not a substitute for secret wiring. Kamal must pass PAYSTACK_* and webhook secrets into the container; git-tracked deploy.yml is not a secrets store (empty CI checks are not green either — same discipline, different layer).
What we rejected
| Temptation | Why it dies |
|---|---|
| Keep domain Paystack forever “for safety” next to Pay | Two recon paths forever |
| Delete money-gem and let Pay own balances | Kills custom currencies and admin FX |
| Add every Pay-supported PSP because the gem lists them | Integration theater; ops cost is real |
| Client-side “paid” UI as fulfillment | Webhook is the truth |
| Skip rate seeds until after launch week | ZAR charge succeeds, RB credit raises |
Practical checklist (steal this)
If you are wiring fiat into a Rails product for Zim/ZA/diaspora rails:
- One initialize → webhook → fulfill path per external hop.
- Local charge row before redirect, with purpose and money object fields.
- Signature fail-closed outside local env; single secret helper.
- Metadata allowlist; server owns identity fields.
- Amount+currency equality check before credit.
- Idempotent fulfill (replayed webhooks no-op).
- Wallet credit only through money-gem construction — no raw integer “cents” helpers that disagree with subunit.
- Explicit FX pairs for every launch currency → platform credit unit.
- Fake processor banned in production boot.
- Deploy secrets presence check (never print values) before calling the path live.
What’s next
New methods should look like new Pay processors + purpose rules, not new snowflake controllers. money-gem stays the spend side. Product still decides which purposes are LNR-critical (lead budgets, wallet top-up, RB purchase) and which stay flagged off.
If you are choosing a stack this quarter: optimize for one recon story and one wallet truth. Clever dual adapters feel fast on week one. They bill you every incident after.
Field notes from building Muchiround. Questions or corrections: the team at Kudapara.
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

Aug 6, 2026
Muchiround Video Studio: Premise In, Short Film Out
How we built generative film production inside Muchiround — storyboard, shot gen, multi-track audio, ffmpeg export, publish to TV — and why creators should open the cutting room.

Aug 6, 2026
Weakest Sufficient Beats Clever Process
How a 2023 generalisation paper became our ops selection rule — one owner per ship unit, weakest briefs, and the hard lines we refuse to weaken.

Jul 30, 2026
Empty Checks Are Not Green
When GitHub Actions minutes hit zero, local bin/ci plus gh signoff became our real merge gate — and why a successful prod hot-patch still is not a waiver.