Never add amounts across different currencies. Maintain separate ledger balances per currency. When you need a unified report, convert to a base currency using a documented exchange rate. Store the rate used so the calculation is reproducible. Paystack operates separate accounts per country, so your settlement reconciliation runs per currency independently.
Paystack operates in Nigeria (NGN), Ghana (GHS), Kenya (KES), South Africa (ZAR), and supports USD transactions. If your business serves customers in multiple countries, you likely have separate Paystack accounts for each country, each with its own API keys, settlement schedule, and fee structure.
Each country account operates independently:
Your application may use a single codebase with multiple Paystack keys, routing customers to the correct country account based on their location or currency preference. Your ledger must handle all currencies correctly.
For the foundational ledger design, see the payments ledger design guide.
Your ledger already has a currency column. The rule is simple: every query that aggregates amounts must include GROUP BY currency.
-- CORRECT: Revenue by currency
SELECT
currency,
SUM(gross_amount) as total_gross,
SUM(fee_amount) as total_fees,
SUM(net_amount) as total_net,
COUNT(*) as transaction_count
FROM payment_ledger
WHERE event_type = 'charge.success'
AND created_at >= '2026-07-01'
AND created_at < '2026-08-01'
GROUP BY currency;
-- WRONG: This mixes currencies
-- SELECT SUM(gross_amount) FROM payment_ledger WHERE ...
-- If you have NGN, KES, and GHS transactions, this number is meaningless
Build this rule into your query templates. If any aggregation query on your payment tables lacks a GROUP BY currency, treat it as a bug and fix it.
// Helper to enforce currency-grouped queries
async function getRevenueByCurrency(fromDate, toDate) {
var result = await db.query(
'SELECT currency, SUM(net_amount) as total '
+ 'FROM payment_ledger '
+ 'WHERE event_type = $1 '
+ 'AND created_at >= $2 AND created_at < $3 '
+ 'GROUP BY currency',
['charge.success', fromDate, toDate]
);
// Return as a map: { NGN: 5000000, KES: 2000000, GHS: 500000 }
var revenue = {};
for (var i = 0; i < result.rows.length; i++) {
revenue[result.rows[i].currency] = parseInt(result.rows[i].total);
}
return revenue;
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Your CFO or accountant wants a single "total revenue" number. They do not want five separate numbers in five currencies. To produce a unified report, convert each currency to a base currency.
// Currency conversion for reporting
async function getUnifiedRevenue(fromDate, toDate, baseCurrency) {
var revenuePerCurrency = await getRevenueByCurrency(fromDate, toDate);
// Fetch exchange rates (from your rates table or an external API)
var rates = await getExchangeRates(baseCurrency);
var totalInBase = 0;
var breakdown = [];
var currencies = Object.keys(revenuePerCurrency);
for (var i = 0; i < currencies.length; i++) {
var currency = currencies[i];
var amount = revenuePerCurrency[currency];
var rate = rates[currency];
if (!rate) {
console.error('No exchange rate for ' + currency + ' to ' + baseCurrency);
continue;
}
var converted = Math.round(amount * rate);
totalInBase += converted;
breakdown.push({
currency: currency,
original_amount: amount,
rate: rate,
converted_amount: converted,
base_currency: baseCurrency,
});
}
return {
total: totalInBase,
base_currency: baseCurrency,
breakdown: breakdown,
rates_date: rates._date,
};
}
// Exchange rates table
// Store daily rates for reproducibility
async function getExchangeRates(baseCurrency) {
var result = await db.query(
'SELECT source_currency, rate FROM exchange_rates '
+ 'WHERE base_currency = $1 '
+ 'AND rate_date = (SELECT MAX(rate_date) FROM exchange_rates WHERE base_currency = $1)',
[baseCurrency]
);
var rates = { _date: null };
for (var i = 0; i < result.rows.length; i++) {
rates[result.rows[i].source_currency] = parseFloat(result.rows[i].rate);
}
return rates;
}
Store the exchange rate used for each conversion. When an auditor asks "how did you calculate total revenue of USD 50,000 for July?", you need to show the rate you used for each currency. If you use the spot rate at the time of each transaction versus a monthly average rate, the total will be different. Pick a policy and apply it consistently.
-- Exchange rates table
CREATE TABLE exchange_rates (
id SERIAL PRIMARY KEY,
rate_date DATE NOT NULL,
source_currency VARCHAR(3) NOT NULL,
base_currency VARCHAR(3) NOT NULL,
rate NUMERIC(20, 10) NOT NULL,
source VARCHAR(50),
-- 'cbn' (Central Bank of Nigeria), 'xe.com', 'openexchangerates.org'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(rate_date, source_currency, base_currency)
);
-- Populate daily rates (run as a cron job)
-- INSERT INTO exchange_rates (rate_date, source_currency, base_currency, rate, source)
-- VALUES ('2026-07-20', 'NGN', 'USD', 0.000625, 'openexchangerates.org');
Fetch rates daily from a reliable source (Central Bank rates, Open Exchange Rates API, or XE). Use the same source consistently. Mixing sources introduces inconsistencies because different sources report slightly different rates.
For NUMERIC precision: use at least 10 decimal places for rates. Some African currency pairs have small values (1 NGN = approximately 0.000625 USD). Rounding too aggressively introduces significant errors when multiplied by large transaction volumes.
Each country account settles independently. Your reconciliation job should run separately for each country:
// Multi-country reconciliation
async function reconcileAllCountries() {
var countries = [
{ currency: 'NGN', secretKey: process.env.PAYSTACK_SECRET_KEY_NG },
{ currency: 'KES', secretKey: process.env.PAYSTACK_SECRET_KEY_KE },
{ currency: 'GHS', secretKey: process.env.PAYSTACK_SECRET_KEY_GH },
];
var results = {};
for (var i = 0; i < countries.length; i++) {
var country = countries[i];
try {
var result = await reconcileCountry(country.currency, country.secretKey);
results[country.currency] = result;
console.log('Reconciled ' + country.currency + ': '
+ result.matched + ' matched, '
+ result.missing + ' missing');
} catch (err) {
console.error('Reconciliation failed for ' + country.currency
+ ': ' + err.message);
results[country.currency] = { error: err.message };
}
}
return results;
}
async function reconcileCountry(currency, secretKey) {
var yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
var targetDate = yesterday.toISOString().split('T')[0];
// Fetch from Paystack using country-specific key
var paystackTxns = await fetchTransactionsWithKey(targetDate, secretKey);
// Compare against ledger entries for this currency
var ledgerEntries = await db.query(
'SELECT reference, gross_amount FROM payment_ledger '
+ 'WHERE event_type = $1 AND currency = $2 '
+ 'AND created_at >= $3::date AND created_at < $3::date + INTERVAL '1 day'',
['charge.success', currency, targetDate]
);
// ... standard matching logic ...
return { matched: 0, missing: 0 }; // Replace with actual counts
}
Each country has its own secret key. Store these as separate environment variables. Never mix them, as a Nigerian key will not return Kenyan transactions. For the reconciliation pattern details, see the settlement reconciliation guide.
Your financial reports should show both the per-currency breakdown and the converted total:
// Generate a multi-currency monthly report
async function monthlyReport(year, month) {
var fromDate = year + '-' + String(month).padStart(2, '0') + '-01';
var toMonth = month === 12 ? 1 : month + 1;
var toYear = month === 12 ? year + 1 : year;
var toDate = toYear + '-' + String(toMonth).padStart(2, '0') + '-01';
// Per-currency revenue
var revenue = await db.query(
'SELECT currency, '
+ 'SUM(gross_amount) as gross, '
+ 'SUM(fee_amount) as fees, '
+ 'SUM(net_amount) as net, '
+ 'COUNT(*) as count '
+ 'FROM payment_ledger '
+ 'WHERE event_type = $1 '
+ 'AND created_at >= $2 AND created_at < $3 '
+ 'GROUP BY currency',
['charge.success', fromDate, toDate]
);
// Per-currency refunds
var refunds = await db.query(
'SELECT currency, SUM(gross_amount) as total, COUNT(*) as count '
+ 'FROM payment_ledger '
+ 'WHERE event_type LIKE $1 '
+ 'AND created_at >= $2 AND created_at < $3 '
+ 'GROUP BY currency',
['refund%', fromDate, toDate]
);
// Convert to base currency
var rates = await getExchangeRates('USD');
return {
period: fromDate + ' to ' + toDate,
revenue_per_currency: revenue.rows,
refunds_per_currency: refunds.rows,
exchange_rates: rates,
// Include converted totals
};
}
Always label converted amounts clearly. "Revenue: USD 50,000 (converted at 2026-07-20 rates)" is much better than just "Revenue: USD 50,000" which might be mistaken for actual USD revenue.
Adding amounts across currencies. The most common mistake. Your dashboard shows "Total Revenue: 5,000,000" but does not specify the currency. Is that NGN? KES? A mix? Always display the currency code next to every amount.
Using the wrong exchange rate date. If you convert July revenue using August exchange rates, your numbers change retroactively. Use the rate from the date of each transaction (for accrual accounting) or the rate from the last day of the reporting period (for period-end reporting). Document which approach you use.
Mixing kobo and whole units. NGN amounts are in kobo (multiply by 100). KES amounts in your ledger are also in the smallest unit (cents). But when you display them, you divide by 100 for NGN but also by 100 for KES. Make sure your display formatting handles each currency correctly:
// Format currency amounts for display
function formatCurrency(amountInSmallestUnit, currency) {
var divisors = {
NGN: 100,
GHS: 100,
KES: 100,
ZAR: 100,
USD: 100,
};
var divisor = divisors[currency] || 100;
var majorUnit = amountInSmallestUnit / divisor;
return currency + ' ' + majorUnit.toLocaleString('en', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
// formatCurrency(5000000, 'NGN') -> 'NGN 50,000.00'
// formatCurrency(500000, 'KES') -> 'KES 5,000.00'
Ignoring forex gains and losses. If you hold funds in NGN and the naira depreciates against the dollar before you convert, you have a foreign exchange loss. If you track revenue in USD but receive settlement in NGN, the USD equivalent of your settlement changes with the exchange rate. For a full accounting treatment, you need to track these forex gains and losses. This is an advanced topic best handled with your accountant.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course