--json prints only JSON on stdout (withJsonOutput around every command: logs, tables and prompts to stderr; printJson everywhere; test runs every --json command against the fake stores and parses stdout); prices ppp compares like with like: par = the store's own US-equivalent price (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT) rounded up) scaled by the PPP price level; defaults only lower, only price levels <= 0.5, World Bank high-income at par, CN at par on Apple and skipped on Play, never above par (--only-decreases, --max-ratio, --min-ratio, --max-level, --include-high-income; --floor / --ceiling kept), top-10 revenue market drops > 20 % warned (sales reports, else a static list), unchanged regions out of the sheet; price changes of existing products only with --allow-price-changes, TTY or not (a terminal yes covered them), then their own question listing each old -> new, held section in dry runs (play products, prices import, play / apple subscriptions); pitfalls 61-63; tests

This commit is contained in:
CRTkafa 2026-10-14 17:36:00 +08:00
parent bab3f63227
commit 357319a775
30 changed files with 865 additions and 209 deletions

View file

@ -1,6 +1,6 @@
---
name: ppp-pricing
description: Propose purchasing power parity (PPP) prices for a mobile app's or game's in-app products in every App Store territory and Google Play region with `storebound prices ppp` - World Bank ICP PPP factors (bundled, CC BY 4.0), The Economist Big Mac index (downloaded on the owner's machine) or the owner's own factor CSV, clamped to a floor and ceiling of the US price, snapped to real App Store price points and clean local Play prices, written as a price sheet that `storebound prices import` applies after its own diff and confirmation. Use when the user asks for regional pricing, PPP or purchasing power pricing, cheaper prices in emerging markets, localized price tiers, or "price by country".
description: Propose purchasing power parity (PPP) prices for a mobile app's or game's in-app products in every App Store territory and Google Play region with `storebound prices ppp` - World Bank ICP PPP factors (bundled, CC BY 4.0), The Economist Big Mac index (downloaded on the owner's machine) or the owner's own factor CSV, applied to the store's own US-equivalent price (VAT included) only in lower-income economies and never upward by default, snapped to real App Store price points and clean local Play prices, written as a price sheet that `storebound prices import` applies after its own diff and confirmation. Use when the user asks for regional pricing, PPP or purchasing power pricing, cheaper prices in emerging markets, localized price tiers, or "price by country".
---
# PPP pricing (proposal sheet, both stores)
@ -9,15 +9,31 @@ CLI: `storebound` (or `bunx storebound`, or `bun <toolchain>/cli/index.ts`). Thi
CSV and prints a diff; nothing reaches a store until the owner runs `storebound prices import` and confirms there.
## What it computes
Per product, store and region:
- `usd = base x PPP factor / exchange rate` (the PPP price, back in US dollars at the market rate).
- Clamped to `floor..ceiling` of the base (defaults 0.3 and 1.2): no region below 30 % or above 120 % of the US price.
- Converted to the currency the store uses there (some App Store storefronts are priced in USD or EUR) and snapped:
- App Store, online: the nearest real price point of that product in that territory (Apple takes nothing else).
- Google Play, and the App Store offline: a clean local ending (x.99, 0.x9, whole numbers ending in 9; round
numbers for JPY, KRW, VND, IDR, HUF, CLP, TWD and every value over 1000). Snapping can move a price a little past
the floor or ceiling.
- The US row is always the base itself.
Like with like: store prices include VAT and follow today's exchange rate, the US price excludes sales tax, and the
PPP price level compares net prices. So the proposal starts from the store's own conversion of the US price and
scales it by the price level. Per product, store and region:
- `level = PPP factor / exchange rate` (same year): the region's price level against the US.
- `par` = the store's US-equivalent price there: Play `convertRegionPrices`, Apple the equalizations of the USA price
point (both VAT included, today's rate). Offline: `base x rate x (1 + VAT)` rounded up to a clean price (standard
VAT rates in `cli/lib/ppp.ts`; the dataset's rate stands in for today's).
- `share`:
- 1 (par) for the US, World Bank high-income economies (unless `--include-high-income`), mainland China, and every
economy whose price level is above `--max-level` (default 0.5: only economies at or below half the US price
level get a PPP price);
- else `clamp(level, --min-ratio 0.3, --max-ratio 1)`: never below 30 % and never above the US-equivalent price.
- `price = snap(par x share)`; with `--only-decreases` (the default; `--no-only-decreases` turns it off) never above
the store's current price.
- Snapping: App Store online, the nearest real price point of that product in that territory (Apple takes nothing
else); Google Play and the App Store offline, a clean local ending (x.99, 0.x9, whole numbers ending in 9; round
numbers for JPY, KRW, VND, IDR, HUF, CLP, TWD and every value over 1000).
- China: Google Play is not available in mainland China (no Play rows); on the App Store CN keeps the store's price.
- Top markets: a top-10 revenue market (from the sales reports in `.storebound/reports/`, `storebound reports
apple-sales` / `play-sales`, else US JP CN KR GB DE FR CA AU TW) that would drop more than 20 % is warned about.
- Only regions whose price changes go into the sheet (`rows`); regions that keep their price are listed as `kept`, so
`prices import` leaves the store's own price there.
Example (bundled World Bank data, USD 4.99, defaults): Germany EUR 5.99 (high-income, par; the old method gave EUR
3.59), UK, France, Japan at par; India and Turkey drop (price levels 0.23 and 0.41); Mexico stays (level above 0.5).
## Data sources (the owner picks one)
- `worldbank` (default, bundled `cli/data/ppp-worldbank.json`): World Bank PA.NUS.PPP (PPP conversion factor, GDP)
@ -28,17 +44,20 @@ Per product, store and region:
(writes `.storebound/ppp-bigmac.json`, gitignored).
- `file`: the owner's own CSV, `region,factor,currency,fx` (`fx` optional: then the bundled rate when the currency
matches). `--file <csv>` or `prices.ppp.file` in the config.
Config (optional): `prices: { ppp: { source, file, floor, ceiling, out } }`.
Config (optional): `prices: { ppp: { source, file, minRatio, maxRatio, maxLevel, onlyDecreases, includeHighIncome, out } }`
(`floor` / `ceiling` are the old names of `minRatio` / `maxRatio`).
## Steps
1. `storebound check`. Products must be in `products[]` with a `usd` base. For real App Store price points the
products must exist on App Store Connect (`storebound apple iap`); otherwise those rows are offline proposals.
2. Dry run and show it to the owner:
`storebound prices ppp --dry` (all products; `--only id1,id2`, `--store apple|play`, `--base 4.99` to try another
US price, `--floor 0.4 --ceiling 1.1`, `--source bigmac|file`, `--offline` to skip every store read).
US price, `--min-ratio 0.4`, `--max-level 0.6`, `--include-high-income`, `--source bigmac|file`, `--offline` to
skip every store read). Change a default only when the owner asks for it.
It prints the sheet, the diff against the stores' current prices (offline: every row "new") and the regions
without a proposal.
3. The owner decides: base price, floor and ceiling, and which regions to keep. Then write the sheet:
3. The owner decides: base price, the rules above if not the defaults, and which regions to keep. Name every top-market
warning to the owner. Then write the sheet:
`storebound prices ppp` (default `store/prices-ppp.csv`, `--out` to change it).
4. The owner reviews and edits the CSV (delete rows to keep a store's own conversion, round to a house price ladder).
5. Apply through the normal price sheet flow: `storebound prices import --csv store/prices-ppp.csv --dry`, then
@ -50,9 +69,8 @@ Config (optional): `prices: { ppp: { source, file, floor, ceiling, out } }`.
- PPP is a proposal, not a rule. Cheaper prices can cannibalize, invite VPN arbitrage on web checkouts and change
revenue; the owner decides region by region.
- Taxes: App Store and Play customer prices are tax-inclusive in most regions (EU VAT, UK VAT, many others), while
the US base is tax-exclusive. PPP factors describe consumer price levels including taxes, so the sheet is a
customer price; proceeds per region differ by the local tax. Play's convertRegionPrices also returns tax-inclusive
prices.
the US base is tax-exclusive. That is why par is the store's own (tax-inclusive) conversion and the PPP level only
scales it; never compare a PPP price in dollars with a local store price directly.
- Offline values: Apple only accepts its own price points and its own storefront currency (USD or EUR in many
smaller storefronts). An offline sheet is a starting point; `prices import --dry` shows what does not fit.
- An App Store price increase cannot be undone once it starts.
@ -60,9 +78,11 @@ Config (optional): `prices: { ppp: { source, file, floor, ceiling, out } }`.
with `bun tools/update-ppp.ts` (`--check` prints its age).
## Output
`--json` prints `{ source: {name, year, retrieved, url, license}, base: {product: usd}, floor, ceiling, online:
{apple, play}, rows: [{product, store, region, currency, price, factor, fx, usdEquivalent, clamped, snappedFrom,
snap}], skipped: [{product, store, region, why}], diff, out, written }`. Use `--json --dry` for a read-only run.
`--json` prints only JSON on stdout (logs on stderr): `{ source: {name, year, retrieved, url, license}, base:
{product: usd}, floor, ceiling, options, online: {apple, play}, rows: [{product, store, region, currency, price,
factor, fx, level, par, parFrom, share, reason, current, usdEquivalent, snappedFrom, snap}], kept: [same shape],
skipped: [{product, store, region, why}], diff, topMarkets: {markets, from, drops}, out, written }`. Use
`--json --dry` for a read-only run.
## Out of scope
- Writing prices to a store (that is `storebound prices import`, after the owner's yes).

View file

@ -33,10 +33,12 @@ Auto-renewable subscriptions are a separate list (`subscriptions`, `store-subscr
New products (with their first prices) and listing text go straight through; `--yes` confirms activating the
purchase option of an existing product. Price or region changes of an EXISTING product are the owner's decision
and `--yes` never confirms them (also not the `--yes` that `flow run --apply --yes` adds): the dry run marks those
rows `owner`, and a real run writes everything else, prints `price change on <id> (<from> -> <to>) needs the
owner: re-run with --allow-price-changes` and exits 3 (declined; a flow stops there). After the owner's yes in
chat for exactly that diff, run `storebound play products --allow-price-changes`; the owner can also answer the
question in a terminal (no `--yes`) or use the desktop Prices "Apply" (owner action).
rows `owner` and lists them in a "price changes on existing products (held)" section, and a real run writes
everything else, prints `price change on <id> (<from> -> <to>) needs the owner: re-run with
--allow-price-changes` and exits 3 (declined; a flow stops there). A terminal "y" never covers them either: only
`--allow-price-changes` does, and then a second question lists each old -> new. So adding new products to the
config and re-running is safe without `--only`. After the owner's yes in chat for exactly that diff, run
`storebound play products --yes --allow-price-changes`, or the owner uses the desktop Prices "Apply" (owner action).
3. Verify: `storebound play status` lists every product with `buy:ACTIVE`.
4. Test: Play Console > Settings > License testing: add tester Gmail accounts; install from the internal testing
link; purchases are free test orders.

View file

@ -712,3 +712,31 @@ that fails without the fix (see CONTRIBUTING.md).
one owner question list. Remove an area from `unanswered` only after the owner answered it.
- **Guard:** `cli/lib/template-answers.ts`, `cli/lib/onboard-declarations.ts`, the `template-answers` scan rule,
`tests/template-answers.test.ts` (init blocks, data-safety --dry refuses, onboard on auth code without an ad SDK).
## 61. `prices ppp --json` printed log lines on stdout before the JSON
- **Do:** with `--json`, read stdout as one JSON document and stderr as the log. Every command run with `--json` goes
through `withJsonOutput` (cli/lib/log.ts, wrapped in cli/index.ts): log / done / tables go to stderr, `printJson` is
the only writer to stdout, prompts go to stderr. Never print a result with `console.log(JSON.stringify(...))`.
- **Guard:** `tests/json-stdout.test.ts` runs every command whose module reads `--json` against the fake stores and
`JSON.parse`s its stdout, plus one real CLI run (`prices ppp --offline --dry --json`).
## 62. PPP prices cut Germany to EUR 3.59 for a USD 4.99 product (store price EUR 5.99)
- **Do:** trust `prices ppp` defaults: only lower prices, only economies with a price level at most 50 % of the US,
World Bank high-income economies and mainland China keep the store's own US-equivalent price, never above it; a
top-10 revenue market dropping more than 20 % is warned about. The old math set a net price (base x PPP level x
exchange rate) against VAT-inclusive store prices and cut every region. Now par = the store's own conversion (Play
convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT), rounded up), share = clamp(level, 0.3, 1).
Regions that keep their price are left out of the sheet (`kept` in `--json`), so `prices import` leaves the store's
price there. `--include-high-income`, `--max-level`, `--min-ratio` / `--max-ratio`, `--no-only-decreases` widen it,
only after the owner asks.
- **Guard:** `shareOf`, `offlinePar`, `planRows`, `topMarketDrops` (cli/lib/ppp.ts), `tests/ppp.test.ts` (Germany
keeps EUR 5.99, India and Turkey drop, CN skipped on Play).
## 63. Creating new IAPs also changed live prices of existing ones (a terminal "y" covered them)
- **Do:** never rely on a confirmation for price changes. Creating products never changes an existing product's price
unless `--allow-price-changes` is passed, TTY or not; the terminal question of `play products`, `prices import`,
`play subscriptions` and `apple subscriptions` covers creates and other updates only. With the flag, price changes
get their own question listing each old -> new (in a terminal even with `--yes`). Dry runs list them in a separate
"price changes on existing products (held)" section. `apple iap` never changes an existing schedule.
- **Guard:** `allowPriceChanges` / `heldSection` (cli/lib/price-gate.ts), `tests/price-gate.test.ts` (TTY and non-TTY:
a "y" creates the new product and holds the live price; the flag's question names each change).