Prerequisites: 000 - Architecture
The pricing system is the core feature of ASO-Light. It reads current App Store prices via the ASC API, calculates suggested prices using economic indices or live exchange rates, and applies bulk price changes across all 175+ territories.
User selects: Pipeline:
- Index type (Exchange Rate, → PriceCalculator.calculate()
PPP, BigMac, Netflix…) OR FX conversion via api.overx.ai
- Base price ($9.99 USD) → apply_vat() (optional)
- Base territory (US) → apply_currency_rounding() (smart)
- VAT toggle (on/off) OR apply_charming() (.99/.95)
- Charming mode (Smart/None/.99) → nearest Apple price point
→ Preview result
File: backend/app/services/pricing/calculator.py
All calculators implement:
def calculate(self, base_price: Decimal, index_value: Decimal, base_index_value: Decimal) -> Decimal| Calculator | File | Formula |
|---|---|---|
| Exchange Rate | exchange_rate.py |
base_price × exchange_rate (live FX from api.overx.ai) |
| PPP | ppp.py |
base_price × (territory_ppp / base_ppp) |
| Big Mac | bigmac.py |
base_price × (territory_bigmac / base_bigmac) |
| Netflix | netflix.py |
base_price × (territory_netflix / base_netflix) |
| Spotify | spotify.py |
base_price × (territory_spotify / base_spotify) |
| Fixed Payout | fixed_payout.py |
target_payout / (1 - commission_rate) |
PPP, BigMac, Netflix, Spotify, and Exchange Rate all inherit ProportionalCalculator (DRY shared base).
File: backend/app/services/pricing/engine.py
The single entry point for price calculation:
- Picks the appropriate
PriceCalculator - Optionally applies VAT:
price × (1 + vat_rate) - Applies rounding: Smart (currency-aware) or Charming (.99 / .95)
File: backend/app/services/pricing/exchange_rate.py
File: backend/app/services/rates/client.py
When index_type == "exchange_rate", the preview endpoint:
- Fetches live FX rates from
api.overx.ai(GET /api/v1/rates?base=USD, 166 currencies, no auth) - Converts:
raw_price = base_price × exchange_rate - Applies smart currency rounding (see below)
- Finds nearest Apple price point
This mode bypasses the EconomicIndex table entirely — rates are fetched live each time.
File: backend/app/services/pricing/currency_rounding.py
The apply_currency_rounding() function rounds prices to "nice" values appropriate for each currency, with ±10% flexibility from the raw converted price.
| Category | Currencies | Strategy | Examples |
|---|---|---|---|
| Standard .99 | USD, EUR, GBP, AUD, CAD, CHF, etc. | floor + 0.99 |
9.99, 14.99, 29.99 |
| ARS | ARS | Tiered steps + .99 | 49.99, 349.99, 4099.99 |
| JPY, TWD | JPY, TWD | Round to 10s | 990, 1490, 2540 |
| KRW, CLP, COP | KRW, CLP, COP | Round to 100s | 9900, 14900, 24000 |
| VND, IDR | VND, IDR | Round to 1000s | 249000, 418000 |
| INR, PKR | INR, PKR, BDT, LKR | Step 100, suffix -1 | 799, 899, 1499 |
| BRL | BRL | floor + 0.90 |
9.90, 52.90 |
| RUB | RUB | floor + 0.00 |
299.00, 884.00 |
| HUF, ISK | HUF, ISK | Round to 10s | 3990, 4050 |
| PHP, THB | PHP, THB | Round to 10s, suffix -1 | 469, 589 |
- Generate candidate "nice" prices around the raw converted value
- Filter to candidates within ±10% of the raw price
- Pick the closest candidate to the raw price
This allows €14.71 to round UP to €14.99 (within 10%), or ¥1493 to land on ¥1490.
Zero-decimal currencies adapt rounding step to price magnitude:
- JPY < 10000 → round to 10s; JPY >= 10000 → round to 100s
- KRW < 100000 → round to 100s; KRW >= 100000 → round to 1000s
- INR < 1000 → step 100; INR 1000-10000 → step 500
Apple does not allow arbitrary prices — each territory has a fixed set of allowed price points ($0.99, $1.99, $2.99…). The price resolver finds the nearest available Apple price point for any calculated price.
Price points are not bulk-fetched (~140k records, 20+ minutes). Instead, they're looked up per-territory on demand during the apply operation. See 002 - ASC Integration for the cache-first architecture.
Page Load:
GET .../prices → Read from subscription_prices DB table (instant)
Preview:
POST .../prices/preview → Exchange rates from api.overx.ai + DB cache (instant)
Sync (explicit user action):
POST .../sync → Fetch ~175 current prices from ASC API (~2.5s)
Store in subscription_prices table
| Index | Fetcher | Source | Notes |
|---|---|---|---|
| PPP | indices/ppp.py |
World Bank API | Annual update |
| Big Mac | indices/bigmac.py |
Economist GitHub CSV | Semi-annual |
| Netflix | indices/netflix.py |
Seed data | ~70 countries, manual quarterly update |
| Spotify | indices/spotify.py |
Seed data | ~70 countries, manual quarterly update |
File: backend/app/services/indices/base.py
Returns list[IndexRecord] — territory_code, value (multiplier relative to US=1.0), reference_date.
File: backend/app/services/indices/refresh.py
Orchestrates all fetchers, upserts EconomicIndex records by (territory_id, index_type).
Users can save pricing configurations (index type, base price, territory, VAT, charming) as named presets for quick reuse.
File: backend/app/api/v1/presets.py
Model: backend/app/models/preset.py — PricePreset
File: backend/app/services/export/excel.py — ExcelExportService
File: backend/app/services/export/csv.py — CSVExportService
Both implement export_prices(subscription_name, prices) → bytes and import_prices(file_bytes) → list[dict].
File: backend/app/services/asc/price_point_cache.py
Apple price points are discrete (~40-80 per territory). Bulk-fetching all ~140k records takes 20+ minutes, so we cache per-territory JSON files on the filesystem.
backend/.cache/price_points/{product_asc_id}/{alpha2}.json
Each file (~5 KB) contains the full list of price points for one territory. Supports both subscriptions and IAPs via the product_type parameter.
| Method | Purpose |
|---|---|
sync(pricing_svc, concurrency=2) |
Fetch all 175 territories from ASC, store as JSON files |
get(territory_code) |
Read cached price points for a territory (async via asyncio.to_thread) |
status() |
Return {cached_territories, synced_at} |
clear() |
Delete entire cache directory for this product |
Path segments are validated with ^[A-Za-z0-9_-]+$ regex to prevent directory traversal.
When applying prices, each territory is checked against current prices:
| Direction | Threshold | Behavior |
|---|---|---|
| Price increase | > +20% | Territory skipped |
| Price decrease | > -25% | Territory skipped |
The safety check uses nearest_apple_price (or suggested_price as fallback when no Apple price tier is cached). Skipped territories are reported in the apply response with reason, current price, new price, and diff percent.
File: backend/app/api/v1/pricing.py — apply_subscription_prices(), apply_iap_prices()
Users can pin specific territories for manual price management. Pinned territories are excluded from bulk price calculations and instead use a per-territory price entered by the user.
- User toggles pin on a territory row in the PriceGrid
- User enters a custom price for that territory
- Frontend calls
POST .../prices/resolvewith{territory_code, customer_price} - Backend finds the nearest Apple price point matching the requested price
- Manual items are merged into the apply request alongside calculated prices
File: backend/app/api/v1/pricing.py — resolve_manual_price(), resolve_iap_manual_price()
File: frontend/src/components/pricing/PriceGrid.tsx — pin toggle column, editable NumberInput
IAPs share the same pricing workflow as subscriptions: sync, preview, apply, manual pins. The price engine, safety limits, and frontend components (PriceGrid, PriceMultiplierPanel, ExportImportButtons) are reused.
| Aspect | Subscriptions | IAPs |
|---|---|---|
| Price write API | POST /v1/subscriptionPrices (per-territory) |
POST /v1/inAppPurchasePriceSchedules (batch) |
| Price read API | v1 nested relationship | v2 with base64 ID decoding |
| Price points API | v1 | v2 |
| ASC resource type | subscriptionPricePoints |
inAppPurchasePricePoints |
IAP price IDs are base64-encoded and contain {s: iap_id, t: territory_alpha3, p: price_point_num}. The service decodes these, matches against paginated v2 price points to resolve actual customer_price and proceeds.
File: backend/app/services/asc/pricing.py — get_iap_price_schedule()
| Method | Path | Purpose |
|---|---|---|
GET |
.../subscriptions |
List groups + subscriptions (syncs from ASC) |
GET |
.../subscriptions/{sub_id}/prices |
Read current prices from DB |
POST |
.../subscriptions/{sub_id}/sync |
Sync prices from ASC |
POST |
.../subscriptions/{sub_id}/price-points/sync |
Cache Apple price tiers to filesystem |
GET |
.../subscriptions/{sub_id}/price-points/status |
Cache status (territory count, sync date) |
POST |
.../subscriptions/{sub_id}/prices/preview |
Calculate suggested prices |
POST |
.../subscriptions/{sub_id}/prices/resolve |
Find nearest Apple tier for manual price |
POST |
.../subscriptions/{sub_id}/prices/apply |
Apply price changes to ASC |
| Method | Path | Purpose |
|---|---|---|
GET |
.../iaps |
List IAPs (syncs from ASC) |
GET |
.../iaps/{iap_id}/prices |
Read current IAP prices from DB |
POST |
.../iaps/{iap_id}/sync |
Sync IAP prices from ASC |
POST |
.../iaps/{iap_id}/price-points/sync |
Cache IAP price tiers |
GET |
.../iaps/{iap_id}/price-points/status |
Cache status |
POST |
.../iaps/{iap_id}/prices/preview |
Calculate suggested IAP prices |
POST |
.../iaps/{iap_id}/prices/resolve |
Find nearest tier for manual IAP price |
POST |
.../iaps/{iap_id}/prices/apply |
Apply IAP price changes to ASC |
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/prices/export |
Download prices as Excel/CSV |
POST |
/api/v1/prices/import |
Upload and parse Excel/CSV prices |
CRUD |
/api/v1/presets |
Manage saved pricing presets |
GET |
/api/v1/territories |
All 202 territories |
GET |
/api/v1/indices/status |
Last refresh timestamps per index |
POST |
/api/v1/indices/refresh |
Trigger economic index refresh |
File: frontend/src/pages/PricingPage.tsx — Main page with tabs (Subscriptions / IAPs / Localizations)
File: frontend/src/components/pricing/PriceGrid.tsx — mantine-datatable, 175 rows, filterable/sortable, manual pin toggle
File: frontend/src/components/pricing/PriceMultiplierPanel.tsx — Controls (index, base price, VAT, charming), skipped territory alerts
File: frontend/src/components/pricing/PriceDiffBadge.tsx — Color-coded diff badge
File: frontend/src/components/pricing/PresetManager.tsx — Save/load presets inline
File: frontend/src/components/pricing/ExportImportButtons.tsx — Download/upload buttons
File: frontend/src/components/pricing/LocalizationEditor.tsx — Inline localization editor with JSON import
File: frontend/src/components/pricing/ReviewScreenshotUpload.tsx — Screenshot upload/preview for subs and IAPs
| Color | Meaning |
|---|---|
| Blue highlight | Manual (pinned) territory |
| Orange | Skipped (exceeds safety limits) |
| Yellow | Changed (new price differs from current) |
Apple prices are not arbitrary — each territory has ~40-80 discrete price points. The preview endpoint reads from the filesystem cache and returns:
suggested_price: the calculated ideal pricenearest_apple_price: the closest available Apple price pointprice_point_id: the ID to use when applyingwould_be_skipped: whether safety limits would exclude this territory
Always use price_point_id (not suggested_price) when calling the apply endpoint.