Localized Prices with Purchasing Power Parity
Purchasing power parity (PPP) pricing is the decision to charge a developer in India less than one in Switzerland for the same SaaS plan, because $20 means something very different in each economy. You face this the moment your conversion data shows healthy sign-ups but near-zero paid conversion from lower-income markets. This page extends Multi-Currency Checkout & Localization โ that cluster establishes per-currency price books and currency-aware ledgers; here we add the adjustment factor that sets the local price and the controls that stop it from being gamed.
PPP is not the same as currency conversion. Converting $20 to INR gives you the FX price; PPP discounts that price to reflect local purchasing power. The two compose: PPP decides the local number, the multi-currency layer charges and settles it. Get the factor and the geo-verification right and you unlock real revenue; get them wrong and either you leave money on the table or a VPN turns your cheapest tier into everyoneโs price.
Trade-offs
The central choice is how aggressively to discount and how strictly to verify geography. Stricter verification protects revenue but adds friction and false positives; looser verification converts better but leaks the discount.
| Strategy | Revenue capture | Abuse exposure | Friction | Maintenance | Best for |
|---|---|---|---|---|---|
| No PPP (one global price) | Highest per-sale, lowest volume in poor markets | None | None | None | Niche / enterprise tools |
| PPP, IP-only geo check | +20โ40% volume in target markets | High โ VPN defeats it | None | Low | Early experiments |
| PPP, IP + billing-address match | Strong | Medium | Low | Medium | Most SaaS |
| PPP, IP + address + card BIN country | Strong | Low | Medium (mismatch friction) | Medium | Mature, abuse-targeted |
| PPP via verified local entity / tax id | Strong, durable | Very low | High | High | B2B, regulated |
The jump worth making for most teams is from IP-only to the IP + billing-address + card-BIN triangulation: it removes the casual VPN exploit at modest friction, and the discount typically still nets positive even after some leakage.
Step-by-Step Implementation
1. Group countries into PPP tiers
Do not assign a unique factor per country โ bucket countries into a handful of tiers (e.g. World Bank income groups or a PPP index) so the matrix stays maintainable. Each tier carries a discount factor applied to the base USD price.
CREATE TABLE ppp_tier (
tier_id SMALLINT PRIMARY KEY,
label TEXT NOT NULL, -- 'tier_1_high', 'tier_3_low', ...
discount_factor NUMERIC(4,3) NOT NULL -- e.g. 0.450 = pay 45% of base
);
CREATE TABLE country_ppp_tier (
country_code CHAR(2) PRIMARY KEY, -- ISO-3166-1 alpha-2
tier_id SMALLINT NOT NULL REFERENCES ppp_tier(tier_id)
);
2. Apply the factor and round to a local price point
Multiply the base price by the factor, then snap to a psychological price point in the local currency. A raw computed โน742 reads as an error; โน699 reads as a price. Round to a point, do not just truncate.
// base in minor units (USD cents); returns minor units in the local currency
function pppPrice(baseUsdMinor: number, factor: number, usdToLocal: number, niceEndings: number[]): number {
const localMinor = Math.round(baseUsdMinor * factor * usdToLocal);
const whole = Math.round(localMinor / 100);
// snap to nearest "nice" ending below, e.g. 699, 999, 1499
const snapped = niceEndings
.map((e) => Math.floor(whole / 1000) * 1000 + e)
.reduce((best, c) => (Math.abs(c - whole) < Math.abs(best - whole) ? c : best));
return snapped * 100; // โ
persisted as integer minor units
}
3. Persist into the per-country price book
The output is written into the price_book from the parent cluster โ PPP is a build-time computation, not a runtime one. This guarantees a stable, hand-checkable price per country and lets you audit exactly what each market is charged.
INSERT INTO price_book (price_id, currency, amount_minor, tax_inclusive)
VALUES ($1, $2, $3, $4)
ON CONFLICT (price_id, currency) DO UPDATE SET amount_minor = EXCLUDED.amount_minor;
4. Verify geography before honoring a discount
Triangulate signals; honor the discounted tier only when they agree, and fall back to the standard price (never block the sale) on mismatch.
function resolveTier(ipCountry: string, billingCountry: string, cardBinCountry: string) {
const agree = ipCountry === billingCountry && billingCountry === cardBinCountry;
if (agree) return { country: billingCountry, discounted: true }; // โ
grant PPP tier
return { country: billingCountry, discounted: false }; // โ ๏ธ fall back to base price
}
5. Re-baseline on a schedule
FX and inflation drift erode the factor over time. Re-run the price-book build quarterly so a currency that has depreciated 15% does not leave that market accidentally paying the high-income price.
Verification & Testing
Assert the rounding snaps correctly across currencies: a computed โน742 must resolve to a configured nice ending (โน699 or โน749), never to โน742. Test the geo resolver with every agreement/disagreement combination and assert that any mismatch returns discounted: false and still allows the purchase at base price โ a PPP check must never hard-block a paying customer. Verify the price-book build is idempotent: running it twice produces identical amount_minor rows. Add a reconciliation query that flags any country whose PPP price, after the latest FX, has drifted more than a threshold from its tier target, so re-baselining is data-driven rather than calendar-only.
-- Countries whose stored PPP price has drifted from tier intent (re-baseline candidates)
SELECT c.country_code, pb.amount_minor, t.discount_factor
FROM country_ppp_tier c
JOIN ppp_tier t USING (tier_id)
JOIN price_book pb ON pb.currency = country_currency(c.country_code)
WHERE ABS(pb.amount_minor - expected_ppp_minor(t.discount_factor, c.country_code)) > drift_threshold(c.country_code);
Gotchas & Production Pitfalls
- IP-only geo is trivially defeated. A $5 VPN gives anyone your cheapest tier. Triangulate IP, billing address, and card BIN country before granting a discount; without that, PPP becomes a global discount.
- Runtime FX conversion for PPP. Computing the local price live makes it drift every day and round to ugly numbers. Compute at build time, snap to a price point, persist it.
- Forgetting zero-decimal currencies. Snapping JPY to โ699โ minor units is meaningless โ JPY has no minor unit. Apply price-point rounding in the currencyโs actual denomination.
- Hard-blocking on geo mismatch. Treating a VPN signal as fraud and refusing the sale loses genuine travelers and expats. Fall back to the standard price instead; you still get the revenue.
- Never re-baselining. A tier set in a stable FX year quietly becomes wrong after a currency depreciates 20%; schedule a quarterly rebuild or the discount silently inflates.
- Letting PPP prices leak into the ledger as the base amount. The ledger must record the actual presentment amount charged (the PPP price), with its currency โ not the undiscounted base โ or revenue reports overstate every discounted sale.