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.