Building a multi-currency billing system

Elijah SoladoyeElijah Soladoye
9 min read

Ever had to build a system that supports users from different countries, and needed a stable way to handle multiple currencies without breaking your billing or transaction tracking? Especially when your payment gateways don’t offer a reliable way to manage that data?

That was the exact challenge I faced while building a multi-currency billing system. I needed a reliable way to track transactions in each user’s local currency, while keeping everything consistent on the backend.

In this post, I’ll walk you through how I approached it — from choosing the right currency APIs to structuring the database for clean, flexible billing logic. I’ll also share a few code snippets that might save you some time if you’re working on something similar.

For this project, I was building in a Next.js environment. To get everything running smoothly, I used:

  • Prisma – for modeling transactions and handling all database operations.

  • Open Exchange Rates – to fetch and apply real-time currency exchange rates.

  • IPData – to determine the user’s location based on their IP address, which helped in setting the correct default currency, you can also use @vercel/functions for this.

  • Stripe and Paystack - for payment processing.

First up, let’s take a look at the core models — User, Transaction, and ExchangeRate.
These models formed the foundation of the billing system, keeping things organized and easy to extend from the start.

// Prisma schema

model Account {
  id           String        @id @default(cuid())
  name         String
  email        String        @unique
  currency     Currency?
  transactions Transaction[]
  createdAt    DateTime      @default(now())
  updatedAt    DateTime      @updatedAt
}

model ExchangeRate {
  id        Int      @id @default(1) // Single record for rates
  rates     Json
  updatedAt DateTime @default(now()) @updatedAt
}

model Transaction {
  id        String          @id @default(cuid())
  apiId     String?         @unique
  reference String?         @unique
  provider  Provider        @default(STRIPE)
  currency  Currency        @default(GBP)
  account   Account         @relation(fields: [accountId], references: [id], onDelete: Restrict)
  accountId String
  type      TransactionType
  amount    Float
  usdAmount Float           @default(0)
  status    String
  paidAt    DateTime        @default(now())
  createdAt DateTime        @default(now())
  updatedAt DateTime        @updatedAt
}

enum TransactionType {
  CREDIT
  DEBIT
}

enum Currency {
  NGN
  USD
  GBP
}

enum Provider {
  STRIPE
  PAYSTACK
}

Let’s dive into the key parts of each model:

The Account model has a currency field that indicates which currency the user prefers to be billed in. For this project, that currency is initially set based on the user's IP address. You can always build a system later to let users update it manually if needed.

The Transaction model stores important details for each transaction. Ideally, you want it to identify both the source and destination of a transaction. You could model this by using user emails as identifiers instead of relying strictly on foreign keys, or just soft-delete accounts to preserve the history — it’s up to you.

The Transaction model stores important details for each transaction. Ideally, it should identify both the source and destination. You could model this by using user emails as identifiers instead of strictly relying on foreign keys, or simply soft-delete accounts to preserve history, it’s up to you.
You’ll also notice a currency field. This helps determine the currency of the amount field and is important for future calculations, especially if the associated account ever gets fully deleted.

Another key field is the usdAmount . This serves as the standard amount across the entire system. Any calculations on credits, debits, or anything related to transactions will use this field. It’s calculated using the ExchangeRate model and real-time rates fetched from the API.

There are two types of transactions, credit and debit, and the system currently supports NGN, USD, and GBP currencies.

Now, onto the ExchangeRate model:
There’s always only one record in the database. Its ID is an integer, defaulted to 1. This record gets updated over time based on the latest rates from your chosen API. The rates field is stored as a JSON object. For example:

{
    "GBP": 0.771786,
    "USD": 1, // base rate
    "NGN": 1600,
}

Now let’s get cooking. First, we need to determine the user’s currency.
You’ll need an account with IPData, or if you’re working in a Python environment, you can use a pip package instead.
If you're using IPData in a Next.js app, start by creating an API route:

import { ERROR_MESSAGES } from "@/lib/constants/messages";
import IPData from 'ipdata';
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
    try {
        const ipdata = new IPData(String(process.env.IP_DATA_API_KEY));
        const ipAddress = (req.headers.get('x-forwarded-for') || '').split(',')[0].trim()
        const result = await ipdata.lookup(ipAddress)
        return new NextResponse(JSON.stringify(result), {
            headers: { "Content-Type": "application/json" },
            status: 200,
        });

    } catch (error) {
        return new NextResponse(JSON.stringify({ "message": ERROR_MESSAGES.InternalServerError }), {
            headers: { "Content-Type": "application/json" },
            status: 500,
        });
    }
}

When the API is called, it’ll grab the user’s IP and return data about their location, country, currency, and more.
Remember to restrict API access, ensuring it only accepts requests from your app. You can do this using middlewares, a custom header, and by checking the referrer in the header.
Check their pricing plans to see what fits your needs. Their free plan allows 1,500 requests per day, but if that’s not enough for your traffic, you can find more details here: https://ipdata.co/pricing.html.

Next, create a function to fetch the user details and call the previously defined API route. This function should only be accessible to logged-in users, so ensure your middleware or whatever authentication system you have doesn’t treat the route as public.

const getCurrencyDetails = async () => {
    const result = await axios.get("/api/ipdata")
    const { country_code }: LookupResponse = result.data
    if (country_code) {
        const result = await updateUserCurrency({ countryCode: country_code })
        if (result?.user) setUser(result.user)
    }
}
const getUserDetails = async () => {
    if (!user?.currency && process.env.NODE_ENV !== "development") {
        // this is to account for the fact that your IP in
        // development mode may mess up the api as your ip //check this
        await getCurrencyDetails()
    } else if (!user) {
        let result = await getUser()
        if (result?.user) setUser(result.user)
    }
}

Then, updateUserCurrency should be a server action that updates the user's currency.
The value you get from getUserDetails can be stored in React state, a Zustand store, Redux, or whatever state management system you’re using.

Now when making transactions, we store transaction information gotten from our payment gateway, Paystack or Stripe. Let’s create a server action for this:

export const createTransaction = async (
  data: CreateTransactionRequest,
): Promise<CreateTransactionResponse | null> => {
  try {
    const account = await prisma.account.findUnique({
      where: { email: data.email }, // the email for the user from stripe or paystack
    });
    if (account?.currency) {
      let transaction = null;
      transaction = await prisma.transaction.findFirst({
        where: { apiId: data.apiId },
      });
      if (!transaction) {
        const rates = await getExchangeRates()
        const rate = rates[data.currency]
        transaction = await prisma.transaction.create({
          data: {
            apiId: data.apiId,
            status: String(data.status),
            amount: data.amount,
            usdAmount: data.amount / rate,
            currency: data.currency,
            type: data.type,
            accountId: account.id,
          },
        });
      }
      console.log("transaction.id", transaction.id, transaction.apiId)
      return { transaction };
    }
    return null;
  } catch (error) {
    console.log(error);
    return null;
  }
};

At this point, we also need to determine the value for usdAmount.
This is where we fetch the exchange rate to calculate it properly.
To get the current exchange rates, we’ll create a function that fetches the rates, stores them in the database, and returns them.


import prisma from "@/lib/prisma";
import { ExchangeRates, SupportedRates } from "@/types/exchange-rates";
import { ExchangeRate } from "@prisma/client";
import axios from "axios";

const fetchExchangeRates = async () => {
    try {
        const result = await axios.get("https://openexchangerates.org/api/latest.json", {
            headers: { Authorization: `Token ${process.env.OPEN_EXCHANGE_RATES_API_KEY}` }
        })
        if (result.status === 200) {
            const data: ExchangeRates = result.data
            return data.rates
        } else {
            return null
        }
    } catch (error) {
        console.log(error)
        return null
    }
}

export const getExchangeRates = async (): Promise<SupportedRates> => {
    try {
        let exchangeRate: ExchangeRate | null = null
        exchangeRate = await prisma.exchangeRate.findFirst()
        // Calculate 12 hours ago
        // const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
        // Calculate 1 hour 12 minutes ago
        // const oneHourTwelveMinutesAgo = new Date(Date.now() - (1 * 60 + 12) * 60 * 1000);

        // Calculate 48 minutes ago
        const fortyEightMinutesAgo = new Date(Date.now() - 48 * 60 * 1000);
        if (!exchangeRate || exchangeRate.updatedAt < fortyEightMinutesAgo) {
            const rates = await fetchExchangeRates();
            if (rates) {
                const updatedRates = {
                    USD: rates.USD,
                    GBP: rates.GBP,
                    NGN: rates.NGN
                }
                if (!exchangeRate) {
                    exchangeRate = await prisma.exchangeRate.create({
                        data: { rates: updatedRates }
                    })
                } else {
                    exchangeRate = await prisma.exchangeRate.update({
                        where: { id: 1 },
                        data: { rates: updatedRates }
                    })
                }
            }

        }
        const rates = (exchangeRate?.rates as any as SupportedRates) || {
            "GBP": 0.771786,
            "USD": 1,
            "NGN": 1600,
        }
        return rates;
    } catch (error) {
        console.error("Error to get exchange rates", error);
        return {
            "GBP": 0.771786,
            "USD": 1,
            "NGN": 1600,
        };
    }
};

export interface ExchangeRates {
    disclaimer: string;
    license: string;
    timestamp: number;
    base: string;
    rates: Rates;
}

export interface SupportedRates {
    USD: number
    GBP: number
    NGN: number
}

Now, let’s talk about fetchExchangeRates,

This function pulls the current exchange rates from an external API called Open Exchange Rates, their Free Plan provides hourly updates (with USD as the base currency) and up to 1,000 requests per month.
When integrating, make sure to structure your calls carefully so you don’t burn through your quota.

For example, if you make about 30 API calls per day, that adds up to around 840–930 calls per month, which keeps you safely within the limit.

  • 30 calls/day × 28 days ≈ 840 calls/month

  • 30 calls/day × 29 days ≈ 870 calls/month

  • 30 calls/day × 31 days ≈ 930 calls/month

  • 30 calls/day × 30 days ≈ 900 calls/month

  • 30 calls/day × 31 days ≈ 930 calls/month

  • 30 calls/day × 31 days ≈ 930 calls/month

  • 24hrs/ 30 api calls = 0.8 hours = 48 minutes

So, we’ll set the API to update every 48 minutes.
But just because you have enough quota doesn’t mean you should use it all. You’ll want to consider a few things:

  • How frequently you’re writing to your database.

  • How these writes impact the speed of your application.

  • How often exchange rates actually change, are you really updating any values, or just making unnecessary writes?

Now what does getExchangeRates do?

It first fetches the stored rates from the database. If none are found, or if 48 minutes have passed since the last update, it calls the external API and updates the database with the new rates.
If for any reason this process fails, a default set of rates is returned — based on the developer’s or stakeholder’s discretion.

Building a multi-currency system comes with a lot of moving parts, but with the right structure, it stays clean and flexible. This approach isn’t perfect, but it serves as a solid foundation for building one. There are also other technologies out there that might handle parts of this process without relying on external APIs — depending on your stack and needs.

Hopefully, walking through my approach gave you a solid starting point — and maybe saved you a few headaches along the way. If you’re building something similar, just remember: keep it simple, keep it scalable.

That’s all for now! If you have any questions or suggestions for improvement, don’t hesitate to reach out. Wishing you all the best!

0
Subscribe to my newsletter

Read articles from Elijah Soladoye directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Elijah Soladoye
Elijah Soladoye