Bonaventure OgetoBy Bonaventure Ogeto|

Currency Conversion Accounting for Multi-Country Paystack Merchants

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.

How Multi-Country Paystack Works

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:

  • Nigerian customers pay in NGN. Settlements go to your Nigerian bank account in NGN.
  • Kenyan customers pay in KES. Settlements go to your Kenyan bank account (or M-Pesa) in KES.
  • Ghanaian customers pay in GHS. Settlements go to your Ghanaian bank account in GHS.

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.

Currency-Aware Ledger Design

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;
}

Converting to a Base Currency for Reports

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.

Storing Exchange Rates

-- 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.

Per-Country Settlement Reconciliation

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.

Financial Reports Across Currencies

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.

Common Multi-Currency Pitfalls

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.

Key Takeaways

  • Never add amounts across different currencies. NGN 100,000 plus KES 10,000 is not a meaningful number.
  • Maintain separate balances per currency in your ledger. Aggregate all queries by currency.
  • Paystack typically requires separate business accounts per country. Each account settles in its local currency.
  • For unified reporting, convert all currencies to a base currency (usually USD or your home currency) using a consistent exchange rate.
  • Store the exchange rate used for each conversion so the calculation can be audited and reproduced later.
  • Settlement reconciliation runs independently per currency. Each country account has its own settlement schedule.

Frequently Asked Questions

Do I need separate Paystack accounts for each country?
Typically yes. Paystack operates country-specific accounts with local settlement. A Nigerian Paystack account settles in NGN to a Nigerian bank. For Kenya, you need a Kenyan Paystack account. Contact Paystack for multi-country setup details.
Can a customer in Nigeria pay in KES?
Generally no. Each Paystack country account accepts its local currency. A Nigerian customer pays in NGN. Cross-currency payments within Paystack are not standard. If you need this, you would handle the currency selection in your application and route to the correct Paystack account.
Which exchange rate source should I use?
Use a consistent, reputable source. Central bank rates (CBN for Nigeria, CBK for Kenya) are official but may not reflect market rates. Commercial rate providers like Open Exchange Rates or XE provide market rates. Choose one and use it consistently.
How do I handle a product priced in USD but paid in NGN?
Price the product in the local currency at checkout using the current exchange rate. Store the local currency amount as the expected amount. The customer pays in local currency, and your ledger records the local currency amount. Convert to USD for reporting using your exchange rate table.
Should I store amounts in a single base currency in my ledger?
No. Store amounts in the original transaction currency. Convert to a base currency only for reporting. If you store in a base currency, you lose the original amounts, and when exchange rates change, you cannot recalculate correctly.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon