
SaaS
June 2026
6 min read
Backend Engineer
Client confidential
Zero-Downtime Currency Migration for a Live Wallet System
Migrated ~16,000 live user wallets from NGN to USD on a platform with 150,000+ transaction records — without touching the ledger, without data loss, and with a 15-minute maintenance window.
0
Ledger rows modified
~16k
Wallets migrated
15 min
Maintenance window
Some clients prefer not to be named publicly. We honor that. These write-ups describe the problem, our approach, and the outcome. Never confidential details.
Written by
Relev Works EngineeringBackend EngineerAt a glance
Problem
A SaaS platform serving 16,000+ users needed to move from NGN to USD as its base currency. Direct balance updates were impossible — the wallet library recomputes balances from the ledger on every refresh, so any naive UPDATE would be overwritten on the next transaction.
Approach
Genesis Reset: zero the NGN wallet with a single balancing withdrawal (ledger intact), create a USD wallet per user, deposit the converted opening balance at a fixed rate, and route WalletService to USD with NGN fallback — all inside a single Artisan command run in per-user transactions.
Result
Platform reopened after a 15-minute maintenance window with balances displayed in USD, mathematically derived from NGN at the agreed rate. Full transaction history preserved. No manual reconciliation.
Write-up
Context
The NGN-to-USD move was driven by two compounding pressures:
- International expansion. The platform was growing beyond Nigeria. International users struggled to reason about pricing denominated in Naira.
- Naira volatility. The NGN/USD rate was shifting fast enough that NGN-denominated balances were becoming unreliable as a store of value for users who thought in dollars.
The wallet system in use was bavix/laravel-wallet — a Laravel package that stores wallet balance as a cached snapshot, not a static column. The snapshot is always recomputed from the transaction ledger on refreshBalance(). This is the right design for financial correctness. It was also the core constraint we had to work around.
Problem
Approximately 16,000 users had active wallets with NGN balances at the time of migration. The transaction ledger had 150,000+ rows, all denominated in kobo.
Three naive approaches were considered and rejected:
Option 1 — Direct UPDATE on wallets.balance
Simple, but broken. bavix calls refreshBalance() after any wallet interaction, recomputing balance from the kobo ledger and overwriting the change. The update would not survive the first user transaction.
Option 2 — Convert all 150,000 ledger rows
Mathematically correct but operationally dangerous. Touching 150,000 financial records in production introduces audit risk, rollback complexity, and the possibility of mid-migration state where some rows are converted and others are not. Not acceptable.
Option 3 — Tag-and-Freeze historical records
Useful for non-wallet data (orders, deposits, marketplace records). Old records tagged NGN, new records default to USD. But this doesn't solve the wallet balance problem — the ledger still drives the balance calculation, and the ledger is still in kobo.
None of these worked cleanly. The wallet system's own integrity mechanism was the obstacle.
Constraints
- 16,000+ active users. The platform could not go dark for hours.
- 150,000+ transaction records could not be modified — the audit trail had to remain intact.
bavix/laravel-walletrecomputes balance from ledger on every refresh. Any balance written externally gets overwritten.- The migration had to be reversible in testing and deterministic in production.
- A single exchange rate had to be applied consistently across all wallets.
Solution: The Genesis Reset
The key insight was to stop treating the wallet system as an obstacle and use it as the migration mechanism.
bavix/laravel-wallet supports multiple wallets per user natively. Each wallet is a separate ledger. The balance of wallet A has no effect on wallet B.
The Genesis Reset works in three steps:
Step 1 — Zero out the NGN wallet via a proper withdrawal
Instead of deleting or modifying historical transactions, a single withdraw() transaction is issued for the exact NGN balance. This leaves the NGN ledger intact and mathematically consistent — deposits minus withdrawals equals zero. The historical record is untouched.
// Zero out NGN wallet with a single balancing withdrawal
$user->wallet->withdraw($user->wallet->balance, [
'description' => 'Genesis Reset — currency migration to USD',
'meta' => ['migration' => 'genesis-reset', 'rate' => $rate]
]);Step 2 — Create the USD wallet and deposit the converted balance
A new USD wallet is created for the user. The NGN balance is converted to USD at the specified rate and deposited as the opening balance of the new wallet.
// Create USD wallet and deposit converted balance
$usdWallet = $user->getWallet('usd') ?? $user->createWallet(['name' => 'USD Wallet', 'slug' => 'usd']);
$usdBalance = intval($ngnBalance / $rate);
$usdWallet->deposit($usdBalance, [
'description' => 'Genesis Reset — opening USD balance',
'meta' => ['migration' => 'genesis-reset', 'source' => 'ngn', 'rate' => $rate]
]);Step 3 — WalletService reads USD wallet with NGN fallback
After migration, WalletService checks for the USD wallet first. If it exists, it uses that. If not (for users who had zero balance and no USD wallet was created), it falls back to the default NGN wallet.
public function getActiveWallet(User $user): Wallet
{
return $user->getWallet('usd') ?? $user->wallet;
}This means the migration is non-destructive and incremental by design. Users with no balance are unaffected. Users with balances get a clean USD wallet with the correct opening deposit.
The Artisan command
The entire migration runs as a single Laravel command:
php artisan app:currency-genesis-reset --rate=1500The --rate flag accepts the NGN/USD conversion rate. The command:
- Queries all users with a non-zero NGN wallet balance
- Runs the three-step Genesis Reset per user inside a database transaction
- Logs each conversion with before/after balances for audit
- Skips users with zero balance (no wallet state to migrate)
- Reports total users processed, total NGN converted, total USD created
PaymentService upgrade
As part of the same release, PaymentService gained a requiresConversion() check:
public function requiresConversion(PaymentGateway $gateway): bool
{
return !in_array('USD', $gateway->supportedCurrencies());
}This means NGN-only payment gateways are handled automatically without if statements scattered through the codebase. New gateways declare their supported currencies; the service adapts.
Migration execution
The migration was tested extensively in development and staging before production. On the night of the migration:
- A 15-minute maintenance window was opened.
- The command was run against production with the agreed exchange rate.
- Balances were spot-checked against pre-migration records.
- The maintenance window was closed.
Users returned to a platform where their wallet balance was now displayed in USD, mathematically derived from their NGN balance at the agreed rate, with full transaction history intact.
Why this works
The Genesis Reset is correct because:
- The NGN ledger is never modified.
refreshBalance()on the NGN wallet will always compute zero — the closing withdrawal cancels all historical deposits. No drift possible. - The USD wallet's opening deposit is the canonical balance. It's a single ledger entry, not a cached value that can be overwritten.
- Both wallets are first-class
bavixwallets. The library's integrity guarantees apply to both. - The conversion is auditable. Every migration transaction has metadata tagging it as a Genesis Reset with the rate applied.
Outcome
- 0 downtime beyond the planned 15-minute maintenance window
- 0 ledger records modified — 150,000+ historical transactions untouched
- 0 balance errors — every converted balance matches the NGN source mathematically
- ~16,000 wallets migrated in a single command execution
- 1 command, 1 argument —
php artisan app:currency-genesis-reset --rate=1500 - Full audit trail on every migration transaction
Lessons learned
Use the system's own guarantees, not workarounds. A withdrawal that zeroes the NGN wallet is exactly what refreshBalance() expects to find.
Genesis Reset
-
Use the system's own guarantees, not workarounds. The temptation was to fight
bavix's recomputation behaviour. The correct move was to use it — a withdrawal that zeroes the NGN wallet is exactly whatrefreshBalance()expects to find. -
Financial migrations need a single rate and a single run. Partial migrations create inconsistent state. The command either processes all eligible users or none (each user runs inside a transaction). Partial success is not an option.
-
Test the command until it's boring. The production run was uneventful because the command had been run dozens of times in development. The 15-minute window was conservative — the actual execution was faster. That margin is intentional.
-
Tag everything. Every migration transaction carries metadata. Six months later, any engineer can query the ledger and see exactly which transactions were part of the Genesis Reset, what rate was applied, and what the source balance was.
Technical appendix
Technical problem
150,000+ ledger rows were denominated in kobo. Six rejected approaches included mass ledger conversion and direct balance writes. bavix/laravel-wallet treats balance as a cached snapshot derived from transactions — the integrity mechanism was the constraint.
Technical approach
Genesis Reset: zero the NGN wallet with a single balancing withdrawal (ledger intact), create a USD wallet per user, deposit the converted opening balance at a fixed rate, and route WalletService to USD with NGN fallback — all inside a single Artisan command run in per-user transactions.
Technical outcome
Zero ledger rows modified. ~16,000 wallets migrated in one command execution. Every migration transaction tagged with rate and source metadata for audit. PaymentService gained currency-aware gateway routing without scattered conditionals.
Related case studies
SMS activation platform
Automated Expiry Detection for Virtual Numbers
Built an automated expiry detection and refund system for temporary virtual numbers using Redis sorted sets when the provider offered no webhook support.
Read case study →Avnac Studio
Boreas - Job Queue Architecture for Background Removal at Scale
Built a fast, stateless background-removal API that decouples image upload validation from expensive compute work using Redis queues and worker pools, keeping request latency under 200ms regardless of processing time.
Read case study →