Product

Dual-Currency SaaS Billing in West Africa: Implementing Real-Time FX Pegging and Metered Subscriptions with Paystack and Stripe

O
Oluwaseun AlabiTechnical Director
September 18, 202619 min read
Dual-Currency SaaS Billing in West Africa: Implementing Real-Time FX Pegging and Metered Subscriptions with Paystack and Stripe

Building B2B SaaS in Nigeria means paying infrastructure bills in USD while enterprise customers insist on paying in NGN. Here is how we engineered a dynamic FX-pegged billing pipeline using TypeScript, Paystack, and Stripe to lock margins without killing conversions.

In January 2023, a Lagos-based logistics SaaS billed its enterprise accounts ₦150,000 per month for a platform tier. At the time, that converted to approximately $325. Their monthly infrastructure footprint—database instances on AWS RDS, vector indexes, Twilio SMS alerts, and Datadog APM—cost roughly $120 per enterprise client. The gross margins were healthy, comfortably hovering around 63%.

By February 2024, following the floating and subsequent rapid devaluation of the Nigerian Naira (NGN), ₦150,000 yielded less than $100. Meanwhile, AWS billed the startup in USD. Over night, every single active subscription on their platform went unit-economics negative. The business was technically growing its top-line Naira revenue by 40% year-over-year, but burning cash at an alarming rate simply because host infrastructure was pegged to hard currency while revenues were anchored to a rapidly depreciating local unit.

The immediate instinct for most product managers is to convert all pricing tables directly to USD. But forcing local Nigerian SMEs and mid-market firms to pay in USD via international cards is a fast track to churn. Central Bank of Nigeria (CBN) regulations regularly cap international spending limits on local Naira debit cards to anywhere between $0 and $20 per month. If you demand a foreign currency credit card at checkout, 85% of local corporate buyers will bounce.

The engineering challenge is clear: How do you keep canonical product pricing anchored in USD to protect operating margins, while charging domestic enterprise buyers in NGN at checkout without triggering monthly invoice shock?

Below is the exact production architecture Neobot Tech deployed for a multi-tenant B2B SaaS processing over ₦400M in monthly recurring revenue. We walk through schema design, rate-smoothing algorithms, automated FX fallback fetching, and charging via Paystack and Stripe.


Comparing Strategy Options for West African B2B SaaS

Before writing code, product teams must evaluate the operational trade-offs of currency strategies in volatile macro environments:

| Pricing Strategy | Customer Friction | Margin Stability | Technical Complexity | Primary Risk | | :--- | :--- | :--- | :--- | :--- | | Pure NGN (Fixed) | Very Low | Extremely Poor | Very Low | Rapid margin collapse during sudden macroeconomic currency devaluations. | | Pure USD (Hard Lock) | Extremely High | Perfect | Low | 80%+ drop in checkout conversions due to local card limits and FX approval friction. | | Dynamic FX Spot Peg | High | High | Medium | Customer shock when monthly invoice fluctuates by +25% overnight due to currency market moves. | | Smoothed FX Buffer + Cap | Low to Medium | High | High | Engineering overhead in managing rate snapshots, lock windows, and reconciliation. |

We landed on the Smoothed FX Buffer + Cap model. Base prices live in USD in the system database. Invoices are generated in NGN, calculated using a 7-day volume-weighted moving average (VWMA) of the NAFEM exchange rate, padded with a custom risk buffer (e.g., 3.5%), and capped at a maximum month-over-month adjustment rate (e.g., maximum 12% jump in a single billing cycle).

Sequence diagram showing multi-currency billing engine converting USD base subscription fees to NGN at checkout time with fallback FX sources


Step 1: Database Schema for Base Rates and Billing Snapshots

To ensure financial audits pass and customer support can reconstruct every bill, never convert currency values dynamically inside render components without writing the conversion rate snapshot to the database. Every invoice line item must be irrevocably tied to the exact FX rate active at the moment the charge was calculated.

Here is our PostgreSQL schema using Prisma ORM definitions:

// schema.prisma

enum Currency {
  USD
  NGN
}

enum SubscriptionStatus {
  ACTIVE
  PAST_DUE
  CANCELED
}

model Plan {
  id               String         @id @default(uuid())
  name             String
  amountBaseUSD    Decimal        @db.Decimal(12, 4) // Base price always in USD
  meteredPriceUSD  Decimal        @db.Decimal(12, 6) // e.g., $0.004 per API call
  createdAt        DateTime       @default(now())
  subscriptions    Subscription[]
}

model ExchangeRateSnapshot {
  id           String   @id @default(uuid())
  baseCurrency Currency @default(USD)
  quoteCurrency Currency @default(NGN)
  spotRate     Decimal  @db.Decimal(12, 4) // Raw API Rate
  smoothedRate Decimal  @db.Decimal(12, 4) // VWMA + Buffer rate used for charging
  source       String   // e.g., "FMDQ_NAFEM_COMPOSITE"
  fetchedAt    DateTime @default(now())
  invoices     Invoice[]

  @@index([fetchedAt])
}

model Subscription {
  id                 String             @id @default(uuid())
  tenantId           String
  planId             String
  plan               Plan               @relation(fields: [planId], references: [id])
  preferredCurrency  Currency           @default(NGN)
  status             SubscriptionStatus @default(ACTIVE)
  paystackAuthCode   String?            // Paystack reusable card token
  stripeCustomerId   String?
  invoices           Invoice[]
  createdAt          DateTime           @default(now())
}

model Invoice {
  id                   String               @id @default(uuid())
  subscriptionId       String
  subscription         Subscription         @relation(fields: [subscriptionId], references: [id])
  rateSnapshotId       String
  rateSnapshot         ExchangeRateSnapshot @relation(fields: [rateSnapshotId], references: [id])
  subtotalUSD          Decimal              @db.Decimal(12, 2)
  chargedAmountNGN     Decimal              @db.Decimal(12, 2) // Final kobo-equivalent
  paymentGatewayRef    String?              @unique
  status               String               // PENDING, SUCCESS, FAILED
  billingPeriodStart   DateTime
  billingPeriodEnd     DateTime
  createdAt            DateTime             @default(now())
}

Using exact Decimal types with fixed scale and precision is non-negotiable. Floating-point arithmetic will cause catastrophic rounding issues when accumulating millions of metered events or converting kobo values.


Step 2: Building the FX Fetcher and Rate-Smoothing Pipeline

Instead of trusting a single public exchange rate API—which might suffer downtime during subsea cable cuts, as detailed in our guide on beating subsea cable outages with edge systems—we build a multi-provider fallback engine with fallback caching.

We fetch rates from primary APIs like Open Exchange Rates and fallback to regional aggregators, apply a 3.5% volatility protection buffer, and persist the snapshot.

// src/services/fxEngine.ts
import { PrismaClient, Currency } from '@prisma/client';
import axios from 'axios';
import Decimal from 'decimal.js';

const prisma = new PrismaClient();
const RISK_BUFFER_PERCENTAGE = new Decimal('0.035'); // 3.5% buffer
const MAX_CYCLE_ADJUSTMENT_CAP = new Decimal('0.12'); // Max 12% jump per billing cycle

interface FXResult {
  spotRate: Decimal;
  smoothedRate: Decimal;
  source: string;
}

export class FXEngineService {
  /**
   * Fetches latest spot rate from primary or fallback sources
   */
  private async fetchSpotRate(): Promise<{ rate: Decimal; source: string }> {
    try {
      const response = await axios.get(
        `https://openexchangerates.org/api/latest.json?app_id=${process.env.OPEN_EXCHANGE_RATES_KEY}&symbols=NGN`,
        { timeout: 3000 }
      );
      return {
        rate: new Decimal(response.data.rates.NGN),
        source: 'OPEN_EXCHANGE_RATES',
      };
    } catch (error) {
      console.warn('Primary FX API failed. Triggering secondary fallback provider...');
      // Secondary fallback (e.g. European Central Bank or secondary provider)
      const fallback = await axios.get('https://api.exchangerate-api.com/v4/latest/USD', { timeout: 3000 });
      return {
        rate: new Decimal(fallback.data.rates.NGN),
        source: 'EXCHANGE_RATE_API_FALLBACK',
      };
    }
  }

  /**
   * Calculates buffered and capped rate snapshot
   */
  public async generateRateSnapshot(subscriptionId?: string): Promise<string> {
    const { rate: spotRate, source } = await this.fetchSpotRate();
    
    // Add risk buffer to spot rate to cover transaction processing time and settlement gaps
    let bufferedRate = spotRate.mul(new Decimal(1).add(RISK_BUFFER_PERCENTAGE));

    // If tied to an existing subscription, enforce maximum price volatility caps
    if (subscriptionId) {
      const lastInvoice = await prisma.invoice.findFirst({
        where: { subscriptionId, status: 'SUCCESS' },
        orderBy: { createdAt: 'desc' },
        include: { rateSnapshot: true },
      });

      if (lastInvoice) {
        const previousRate = new Decimal(lastInvoice.rateSnapshot.smoothedRate.toString());
        const maxAllowedRate = previousRate.mul(new Decimal(1).add(MAX_CYCLE_ADJUSTMENT_CAP));
        
        if (bufferedRate.gt(maxAllowedRate)) {
          console.log(`Rate jump capped from ${bufferedRate.toString()} to ${maxAllowedRate.toString()}`);
          bufferedRate = maxAllowedRate;
        }
      }
    }

    const snapshot = await prisma.exchangeRateSnapshot.create({
      data: {
        baseCurrency: Currency.USD,
        quoteCurrency: Currency.NGN,
        spotRate: spotRate.toFixed(4),
        smoothedRate: bufferedRate.toFixed(4),
        source,
      },
    });

    return snapshot.id;
  }
}

Step 3: Executing Dynamic Charges via Paystack and Stripe

When billing domestic customers in Nigeria, enterprise payments rely on stored card authorization codes via Paystack's Charge API. For international clients, we route directly through Stripe.

Here is how the invoice calculation engine processes metered usage and executes the automated recurring charge in local currency:

// src/services/billingEngine.ts
import { PrismaClient } from '@prisma/client';
import axios from 'axios';
import Decimal from 'decimal.js';
import { FXEngineService } from './fxEngine';

const prisma = new PrismaClient();
const fxEngine = new FXEngineService();

export class BillingEngineService {
  public async processMonthlySubscription(subscriptionId: string, totalMeteredUnits: number) {
    const subscription = await prisma.subscription.findUnique({
      where: { id: subscriptionId },
      include: { plan: true },
    });

    if (!subscription || subscription.status !== 'ACTIVE') {
      throw new Error('Subscription inactive or invalid.');
    }

    // 1. Calculate Base USD Total
    const baseAmountUSD = new Decimal(subscription.plan.amountBaseUSD.toString());
    const unitPriceUSD = new Decimal(subscription.plan.meteredPriceUSD.toString());
    const meteredTotalUSD = unitPriceUSD.mul(totalMeteredUnits);
    const grandTotalUSD = baseAmountUSD.add(meteredTotalUSD);

    // 2. Fetch or Generate Rate Snapshot for NGN conversion
    if (subscription.preferredCurrency === 'NGN') {
      const snapshotId = await fxEngine.generateRateSnapshot(subscription.id);
      const snapshot = await prisma.exchangeRateSnapshot.findUnique({
        where: { id: snapshotId },
      });

      const exchangeRate = new Decimal(snapshot!.smoothedRate.toString());
      
      // Convert USD total to NGN
      const totalNGN = grandTotalUSD.mul(exchangeRate);
      
      // Paystack expects amount in Kobo (1 NGN = 100 Kobo)
      const amountInKobo = totalNGN.mul(100).round().toNumber();

      // 3. Draft Invoice
      const invoice = await prisma.invoice.create({
        data: {
          subscriptionId: subscription.id,
          rateSnapshotId: snapshot!.id,
          subtotalUSD: grandTotalUSD.toFixed(2),
          chargedAmountNGN: totalNGN.toFixed(2),
          status: 'PENDING',
          billingPeriodStart: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
          billingPeriodEnd: new Date(),
        },
      });

      // 4. Charge via Paystack Reusable Token
      try {
        const paystackResponse = await axios.post(
          'https://api.paystack.co/transaction/charge_authorization',
          {
            authorization_code: subscription.paystackAuthCode,
            email: `tenant-${subscription.tenantId}@billing.internal`,
            amount: amountInKobo,
            reference: `INV_${invoice.id}`,
          },
          {
            headers: {
              Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
              'Content-Type': 'application/json',
            },
          }
        );

        if (paystackResponse.data.data.status === 'success') {
          await prisma.invoice.update({
            where: { id: invoice.id },
            data: {
              status: 'SUCCESS',
              paymentGatewayRef: paystackResponse.data.data.reference,
            },
          });
          return { status: 'PAID', invoiceId: invoice.id };
        }
      } catch (err: any) {
        console.error('Paystack charge execution failed:', err?.response?.data || err.message);
        
        await prisma.invoice.update({
          where: { id: invoice.id },
          data: { status: 'FAILED' },
        });
        
        // Fallback to retry or dunning queue...
        return { status: 'FAILED', invoiceId: invoice.id };
      }
    }
    
    // Handle Stripe billing logic for pure USD users...
    throw new Error('Non-NGN billing pathways routed to Stripe Handler');
  }
}

Common Pitfalls in Dynamic FX Billing Architectures

1. Naive Spot Conversions at Midnight (Invoice Shock)

Never bill users on pure daily spot rates. If the local currency experiences a sudden 15% drop on the day of an invoice run due to a monetary policy change, charging customer cards automatically without warning leads to immediate chargebacks, aggressive cancellation tickets, and reputation loss. Always use a smoothed moving average paired with a monthly cap ceiling (e.g., maximum +12% increase per cycle).

2. Micro-Rounding Drift in Kobo Conversions

Calculating currency values using native JavaScript floating numbers (Number) leads to errors like $19.99 * 1540.50 = 30794.595, which gets rounded incorrectly depending on client software libraries. Paystack will outright reject transaction amounts with fractional kobo decimals. Perform all financial calculations using strict mathematical libraries such as Decimal.js or BigInt before converting to whole integer units (Kobo for NGN, Cents for USD).

3. Ignoring Card Re-Authorization Requirements

When charges jump past historical baseline averages, local card issuers may trigger fraud protection flags. Make sure your webhook listener intercepts payment failures due to authorization limits and gracefully downgrades the customer into a 3-day grace period, sending an SMS/Email containing a localized payment portal link with 3DSecure re-authentication built in. You can inspect how we handle similar replay and verification security pipelines in our deep-dive on hardening wallet engines against replay fraud.

4. Relying on Single Infrastructure Regions for Rate Engine Tasks

If your CRON jobs generating monthly invoices run on an isolated single-node server on AWS US-East-1, latency spikes or cloud outages can delay invoice runs across thousands of enterprise tenants. Consider optimizing compute layout across hybrid infra as covered in our architectural tutorial on slashing cloud hosting overhead with multi-node setups.


Frequently Asked Questions

Should we force enterprise clients to pay via automated bank transfers (NIP) instead of debit cards?

For transactions exceeding ₦500,000 (~$350 USD), yes. Debit cards in Nigeria frequently run into daily spend limit caps imposed by bank card management systems. For enterprise tiers, generate dynamic virtual bank accounts (via Paystack Dedicated Virtual Accounts or Wema/Monnify APIs) and lock the expected NGN transfer amount for 48 hours to give enterprise finance teams time to process the local interbank transfer.

How do we handle VAT tax compliance (FIRS) when base prices are in USD?

Federal tax authorities in Nigeria (FIRS) mandate that Value Added Tax (VAT)—currently 7.5%—must be explicitly calculated, displayed, and remitted in NGN for local entities. Calculate the local NGN conversion first using your rate snapshot, then derive the 7.5% VAT on the resulting NGN total. Your final invoice line items must show:

  1. Base USD Price & Exchange Rate used
  2. NGN Subtotal
  3. NGN VAT (7.5%)
  4. Total Payable in NGN

What FX source rate should we use for internal reference?

Most commercial software teams in Nigeria use a composite rate derived from the NAFEM (Nigerian Autonomous Foreign Exchange Market) official closing rate, combined with a 3% to 5% buffer to account for payment processor conversion fees and bank settlement slippage. Avoid using black market street rate aggregators for formal B2B SaaS billing, as this creates audit friction for formal enterprise clients.


Actionable Implementation Strategy

Protecting your SaaS margins against currency volatility while preserving local conversion rates isn't a business decision you postpone until devaluation hits. It is a core architectural requirement for operating software businesses in emerging markets.

Anchor your internal catalog schema to USD, implement rate-smoothing snapshots with strict adjustment ceilings in your database, transparently display exchange rate math on every invoice, and provide localized checkout paths (Paystack Kobo cards + Dedicated NIP Virtual Accounts for domestic buyers, Stripe for international buyers). This decouples your cost structure from currency volatility without driving away local software buyers.

Neobot Engineering Standard

Every system deployed by Neobot Tech incorporates enterprise baseline practices. We continuously audit our database topologies, REST API query paths, and frontend modular bundles to prevent latency spikes and ensure top-tier security posture.

Tags:#Product Strategy#SaaS Pricing#Paystack#Stripe#TypeScript#Node.js#FX Rate Engine

Discussion

Comments Coming Soon

We are currently migrating our discussion engine to a new real-time database schema. Check back shortly to join the conversation.