McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Currency Conversion Accounting for Multi-Country Paystack Merchants
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

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.

In this article

  1. 1. How Multi-Country Paystack Works
  2. 2. Currency-Aware Ledger Design
  3. 3. Converting to a Base Currency for Reports
  4. 4. Storing Exchange Rates
  5. 5. Per-Country Settlement Reconciliation
  6. 6. Financial Reports Across Currencies
  7. 7. Common Multi-Currency Pitfalls

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

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

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.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Payment Verification and Reconciliation: Complete Guide

The complete engineering reference for verifying Paystack transactions server-side, preventing double grants, designing a payments ledger, reconciling settlements against your database, and building automated sweeper jobs that catch missing transactions.

Read more

Designing a Payments Ledger for a Paystack Integration

A payments ledger is your source of truth for every financial event in your Paystack integration. This guide walks through the database schema, write patterns, and query strategies that make reconciliation, refund tracking, and financial reporting reliable from day one.

Read more

Double-Entry Bookkeeping for African Payment Integrations

Double-entry bookkeeping is not just for accountants. When you integrate Paystack (or any African payment gateway), recording every transaction as matching debit and credit entries catches errors that single-entry systems miss. This guide shows how to apply the principle in code, with practical PostgreSQL schema and Node.js examples.

Read more

Paystack Fees in Your Ledger: Gross vs Net Recording

When a customer pays NGN 50,000, Paystack takes a processing fee and settles the rest to your bank. How you record this in your ledger determines whether settlement reconciliation is straightforward or a nightmare. This guide covers the gross vs net recording decision and how to implement each approach.

Read more

Reconciling Paystack Settlements Against Your Database

Paystack batches your daily transactions into settlements and deposits the net amount to your bank. Reconciling those settlements against your own records catches missing webhooks, unrecorded refunds, and fee discrepancies before they become accounting nightmares. This guide covers the full reconciliation workflow with code.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.