Implementing Purchasing Power Parity (PPP) in The Boring JavaScript Stack
Kelvin Omereshone
@Dominus_Kelvin
Purchasing Power Parity (PPP) is an economic principle that accounts for the relative cost of living and inflation rates between countries. In simpler terms, $100 in the United States doesn’t have the same buying power as $100 converted to Nigerian Naira or Indian Rupees, even after exchange rates.
For digital product creators, this creates a dilemma. A course priced at $99 might be reasonable for someone in San Francisco, but that same price could represent a week’s salary for a developer in Lagos or Nairobi. By implementing PPP-adjusted pricing, you make your products accessible globally while still capturing revenue you’d otherwise lose entirely.
The business case is compelling too. PPP pricing reduces piracy (people pay what they can afford rather than pirating), cuts down on credit card fraud, and ultimately increases sales. You’d rather make $15 from a legitimate customer than $0 from piracy or lose money to chargebacks.
When we added PPP to The African Engineer, we got our first annual subscription almost immediately. The price was discounted at 85% based on the user’s location, and suddenly what seemed expensive became comfortable for them.
In this post, I’ll show you exactly how to implement PPP pricing in The Boring JavaScript Stack.
The Architecture
Our PPP system has four parts:
- Region Configuration: JSON files with PPP data for each country
- PPP Helpers: Sails helpers to detect location and fetch PPP data
- Pricing Controller: Calculate and display adjusted prices
- Payment Integration: Support multiple providers per currency
Let’s build each piece.
Step 1: Region Configuration
Create a config/regions directory with JSON files for each supported country:
config/
regions/
us.json
ng.json
ke.json
gh.json
za.json
Each file contains PPP data:
// config/regions/ng.json
{
"countryCode": "NG",
"currency": "USD",
"currencySymbol": "$",
"localCurrency": "NGN",
"localCurrencySymbol": "₦",
"pppConversionFactor": 0.12,
"exchangeRate": 1438.97,
"discount": 0.88
}
The pppConversionFactor is key. It represents what fraction of the USD price someone in that country should pay. A factor of 0.12 means 12% of the original price, or an 88% discount.
The baseline (US) has a factor of 1:
// config/regions/us.json
{
"countryCode": "US",
"currency": "USD",
"currencySymbol": "$",
"localCurrency": "USD",
"localCurrencySymbol": "$",
"pppConversionFactor": 1,
"exchangeRate": 1,
"discount": 0
}
How do you calculate the discount?
We use the World Bank PPP API to calculate our conversion factors. The World Bank provides PPP conversion factors that represent the number of units of a country’s currency required to buy the same amount of goods and services in the domestic market as a U.S. dollar would buy in the United States.
Here’s the formula we use:
pppConversionFactor = Country's PPP conversion factor / US PPP conversion factor
discount = 1 - pppConversionFactor
For example, if Nigeria’s PPP conversion factor is 148.55 and the exchange rate is 1438.97 NGN/USD:
pppConversionFactor = 148.55 / 1438.97 ≈ 0.12
discount = 1 - 0.12 = 0.88 (88% discount)
This means a $99 product would cost $12 for someone in Nigeria, a price that reflects actual purchasing power rather than just currency conversion.
Other resources you can use:
- Big Mac Index: The Economist’s fun-but-useful index based on burger prices globally
- Spotify Pricing: See how Spotify adjusts prices across countries for reference
Step 2: PPP Configuration
Add PPP settings to your config:
// config/ppp.js
module.exports.ppp = {
enabled: true,
// Data sources for updating region files
dataSources: {
worldBankApi: 'https://api.worldbank.org/v2/country/{country}/indicator/PA.NUS.PPP',
exchangeRateApi: 'https://api.exchangerate.fun/latest'
},
// Supported currencies
currencies: ['USD', 'NGN'],
// Payment providers by currency
providersFor: {
USD: ['lemonsqueezy'],
NGN: ['paystack']
}
}
Step 3: Location Detection Helper
To apply PPP pricing, we need to know where the user is located. Our approach uses a layered detection strategy:
- First, check for CDN-injected headers (Cloudflare, Vercel, or custom)
- If no headers, fall back to IP geolocation using
geoip-lite - Default to a sensible fallback for local development
Install the required packages:
npm install request-ip geoip-lite
Here’s the helper:
// api/helpers/ppp/get-country-from-request.js
const requestIp = require('request-ip')
const geoip = require('geoip-lite')
module.exports = {
sync: true,
friendlyName: 'Get country from request',
description: 'Detect the country code from the request IP address',
inputs: {
req: {
type: 'ref',
description: 'The current incoming request (req)',
required: true
}
},
exits: {
success: {
outputFriendlyName: 'Country code',
outputDescription: 'The ISO Alpha-2 country code (e.g., NG, US, KE)',
outputType: 'string'
}
},
fn: function ({ req }) {
// Check CDN headers first (fastest, most reliable)
const countryCode =
req.headers['cf-ipcountry'] ||
req.headers['x-vercel-ip-country'] ||
req.headers['x-country-code']
if (countryCode && countryCode !== 'XX') {
return countryCode
}
// Fall back to IP geolocation
const clientIp = requestIp.getClientIp(req)
// Local development fallback
if (!clientIp || clientIp === '127.0.0.1' || clientIp === '::1') {
return 'NG'
}
const geo = geoip.lookup(clientIp)
return geo?.country || 'US'
}
}
The geoip-lite package uses MaxMind’s free GeoLite database, which is bundled with the package. No API calls needed.
Step 4: PPP Data Helper
Create a helper to fetch PPP data for a country with a fallback:
// api/helpers/ppp/get-data.js
const path = require('path')
module.exports = {
sync: true,
friendlyName: 'Get PPP data',
description: 'Get PPP data for a country from region files',
inputs: {
countryCode: {
type: 'string',
required: true,
description: 'ISO Alpha-2 country code (e.g., NG, US, KE)'
}
},
exits: {
success: {
outputFriendlyName: 'PPP data'
}
},
fn: function ({ countryCode }) {
const regionPath = path.join(
sails.config.appPath,
`config/regions/${countryCode.toLowerCase()}.json`
)
try {
return require(regionPath)
} catch {
// Fallback to US if region file doesn't exist
sails.log.warn(`PPP: Region file not found for ${countryCode}, falling back to US`)
return require(path.join(sails.config.appPath, 'config/regions/us.json'))
}
}
}
Step 5: Pricing Page Controller
Now wire it all together in your pricing controller:
// api/controllers/billing/view-pricing.js
module.exports = {
friendlyName: 'View pricing',
description: 'Display pricing page with PPP-adjusted prices',
exits: {
success: {
responseType: 'inertia'
}
},
fn: async function () {
const pppConfig = sails.config.ppp
const countryCode = sails.helpers.ppp.getCountryFromRequest(this.req)
const pppData = sails.helpers.ppp.getData(countryCode)
// Get providers for local currency
const localProviders = pppConfig.providersFor[pppData.localCurrency] || []
// Calculate PPP-adjusted price
const basePrice = 99 // Your base USD price
const pppPrice = Math.round(basePrice * pppData.pppConversionFactor)
return {
page: 'billing/pricing',
props: {
plans: {
annual: {
name: 'Annual',
basePrice,
pppPrice,
discount: pppData.discount
}
},
currency: 'USD',
currencySymbol: '$',
localCurrency: pppData.localCurrency,
localCurrencySymbol: pppData.localCurrencySymbol,
localProviders,
pppDiscount: Math.round(pppData.discount * 100),
countryCode
}
}
}
}
Step 6: The PayLink Component
Create a Vue component(Or React if that’s your UI language) that handles both USD and local currency payments. The split button shows a local currency option when available:
<!-- assets/js/components/PayLink.vue -->
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
plan: { type: String, required: true },
amount: { type: Number, required: true },
currency: { type: String, required: true },
providers: { type: Array, required: true },
localProviders: { type: Array, default: () => [] },
localCurrency: { type: String, default: null },
localCurrencySymbol: { type: String, default: null },
label: { type: String, default: 'Get started' }
})
const hasLocalProviders = computed(() => props.localProviders.length > 0)
const checkoutUrl = computed(() => {
return `/checkout?plan=${props.plan}&provider=${props.providers[0]}¤cy=${props.currency}`
})
const localCheckoutUrl = computed(() => {
if (props.localProviders.length === 1) {
return `/checkout?plan=${props.plan}&provider=${props.localProviders[0]}¤cy=${props.localCurrency}`
}
return null
})
</script>
<template>
<div class="relative inline-flex w-full">
<!-- Main USD button -->
<a
:href="checkoutUrl"
:class="[
'flex-1 flex items-center justify-center rounded-xl px-6 py-4 text-lg font-bold',
'bg-gradient-to-r from-brand-600 to-accent-600 text-white',
hasLocalProviders ? 'rounded-r-none' : ''
]"
>
{{ label }}
</a>
<!-- Local currency button -->
<a
v-if="hasLocalProviders && localCheckoutUrl"
:href="localCheckoutUrl"
:title="`Pay in ${localCurrency}`"
class="flex items-center justify-center rounded-r-xl bg-accent-600 px-4 py-4 text-lg font-bold text-white"
>
{{ localCurrencySymbol }}
</a>
</div>
</template>
Step 7: Display the PPP Discount Banner
Rather than automatically applying the discount, we make it opt-in. This gives users the choice and feels more respectful:
<!-- In your pricing page -->
<div v-if="showPppBanner" class="bg-linear-to-r from-brand-600 to-accent-600 px-4 py-4">
<div class="mx-auto max-w-4xl">
<div class="flex flex-col items-center justify-between gap-4 text-center md:flex-row md:text-left">
<div class="flex items-start gap-3">
<svg
class="h-6 w-6 shrink-0 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="text-sm font-bold text-white md:text-base">
🎉 {{ discountPercentage }}% Regional Pricing Available!
</p>
<p class="mt-1 text-xs text-white/90 md:text-sm">
We want every African engineer to have access. Get {{ discountPercentage }}% off and invest in your engineering career.
</p>
</div>
</div>
<div class="flex shrink-0 gap-2">
<button
type="button"
class="rounded-lg cursor-pointer bg-white px-4 py-2 text-sm font-semibold text-brand-700 transition-all hover:bg-white/90"
@click="acceptPppDiscount"
>
Yes, apply discount
</button>
<button
type="button"
class="rounded-lg cursor-pointer border-2 border-white/30 px-4 py-2 text-sm font-semibold text-white transition-all hover:bg-white/10"
@click="dismissPppDiscount"
>
No thanks
</button>
</div>
</div>
</div>
</div>
The acceptPppDiscount method sets pppAccepted to true in your component state, which then adjusts the displayed prices and checkout URLs to use the discounted amount. The dismissPppDiscount method hides the banner for the current session.
We intentionally don’t persist this choice to localStorage. A user’s PPP eligibility can change if they travel or use a VPN, so we check their location fresh on each visit and let them decide again whether to accept regional pricing.
Step 8: Automated PPP Updates (Optional)
Create a scheduled script to update PPP data monthly using Sails Quest:
// scripts/update-ppp-regions.js
module.exports = {
friendlyName: 'Update PPP regions',
description: 'Fetch latest PPP and exchange rate data',
quest: {
schedule: '0 3 1 * *' // 1st of every month at 3 AM
},
fn: async function () {
const fs = require('fs').promises
const path = require('path')
const REGIONS_DIR = path.join(sails.config.appPath, 'config', 'regions')
const { dataSources } = sails.config.ppp
// Fetch exchange rates
const ratesResponse = await fetch(`${dataSources.exchangeRateApi}?base=USD`)
const ratesData = await ratesResponse.json()
// Update each region file
const regionFiles = await fs.readdir(REGIONS_DIR)
for (const file of regionFiles) {
if (!file.endsWith('.json') || file === 'us.json') continue
const filePath = path.join(REGIONS_DIR, file)
const regionData = JSON.parse(await fs.readFile(filePath, 'utf8'))
// Update exchange rate
if (ratesData.rates[regionData.localCurrency]) {
regionData.exchangeRate = ratesData.rates[regionData.localCurrency]
}
await fs.writeFile(filePath, JSON.stringify(regionData, null, 2))
}
sails.log.info('PPP regions updated successfully')
}
}
Run it manually with:
npx sails run update-ppp-regions
P.S: Quest will run it automatically on schedule when you deploy.
The Result
With this system in place:
- Users see fair pricing: Automatically adjusted based on their location
- Multiple payment options: USD via Stripe or Lemon Squeezy, local currencies via Paga, Paystack, or,Flutterwave
- Transparent discounts: Users know why they’re getting a discount
- Automated updates: PPP data stays current with monthly updates
Conclusion
PPP pricing isn’t complicated to implement, but it makes a massive difference in accessibility. When we added this to The African Engineer, we saw immediate results.
For the philosophical take on why PPP matters, especially for African builders, check out my post Build Globally, Price Locally.