diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0332cc8..4f3b10d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -17,6 +17,16 @@ "skills": [ "./skills/conventional-commits" ] + }, + { + "name": "app-store-suite", + "source": "./", + "description": "Skills for shipping iOS apps through App Store Connect, starting with worldwide territory pricing: Big Mac index, PPP and GNI-band strategies, an estimate-first workflow, and an API key check.", + "keywords": ["app-store-connect", "ios", "pricing", "localization", "ppp", "big-mac-index"], + "category": "development", + "skills": [ + "./skills/territory-pricing" + ] } ] } \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 95fca65..cad248e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,7 +9,7 @@ on: - "skills/**" concurrency: - group: lint-${{ github.head_ref }} + group: lint-${{ github.head_ref || github.ref }} cancel-in-progress: true permissions: @@ -34,4 +34,4 @@ jobs: - name: Discover typos run: | source .venv/bin/activate - codespell --ignore-words-list="hart,inout,msdos,sur" --skip="./.build/*,./.git/*" \ No newline at end of file + codespell --ignore-words-list="hart,inout,msdos,sur" --skip="./.build/*,./.git/*,./skills/territory-pricing/references/data/*" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..40fca24 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: test + +on: + push: + branches: + - main + pull_request: + paths: + - "skills/territory-pricing/**" + +concurrency: + group: test-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + territory-pricing: + name: territory-pricing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Run unit tests + run: ruby skills/territory-pricing/tests/run_all.rb + + - name: Syntax-check scripts + run: | + for f in skills/territory-pricing/scripts/*.rb skills/territory-pricing/scripts/lib/*.rb; do ruby -c "$f"; done + bash -n skills/territory-pricing/scripts/check_setup.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..918fe43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +/territory-pricing/ diff --git a/README.md b/README.md index a407081..57e34bb 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Dev Productivity Kit is a curated ecosystem of production-ready **Skills** and p | Plugin Name | Skill Command | Description | |-----------|----------------|----------------| | git-suite | `/conventional-commits` | Generates semantic commits adhering to Conventional Commits 1.0.0. Max 50 chars for headers, 72 chars hard wrap for bodies, auto-detects BREAKING CHANGE. | +| app-store-suite | `/territory-pricing` | Worldwide per-territory pricing for iOS apps, IAPs and subscriptions. Strategies: GNI bands, PPP, Big Mac index, tiered. Checks the App Store Connect API key, shows a Price Proposal first and writes to App Store Connect only after consent. | ## Author diff --git a/skills/territory-pricing/SKILL.md b/skills/territory-pricing/SKILL.md new file mode 100644 index 0000000..2cd20b2 --- /dev/null +++ b/skills/territory-pricing/SKILL.md @@ -0,0 +1,172 @@ +--- +name: territory-pricing +description: Sets worldwide per-territory prices for an iOS app, in-app purchase or auto-renewable subscription in App Store Connect. Computes a Price Proposal with a chosen pricing strategy (GNI bands, Big Mac index, PPP, tiered, Apple equalization), shows it, and writes to App Store Connect only after explicit consent. Trigger on "territory pricing", "localize prices", "regional pricing", "PPP pricing", "Big Mac index", "worldwide prices", or "региональные цены". +--- + +# Territory Pricing + +You price one App Store product per territory. The user always sees the estimate first; nothing is +written to App Store Connect until they agree. You drive the App Store Connect API yourself through +`scripts/asc.rb`; `scripts/plan.rb` does the arithmetic so you never compute 175 prices by hand. + +`SKILL_DIR` below is the folder that holds this file. Read `references/asc-pricing-api.md` (endpoints, +how to collect the data, how to write) before step 4, and `references/strategies.md` before step 3. + +## Terms + +* **Territory**: an App Store storefront (USA, BRA, JPN, ...), about 175 of them. +* **Base territory**: the territory whose current price anchors the plan, usually USA. +* **Equalized price**: what Apple derives for a territory from the base price at market exchange rates. +* **Price point**: one of the fixed prices Apple allows in a territory. A free-form price does not exist. +* **Pricing strategy**: how the equalized price is scaled per territory. +* **Price Proposal**: the table territory, current price, proposed price point. This is what the user approves. + +## Hard rules + +1. **Estimate before write.** Everything up to the Price Proposal is read-only (`asc.rb GET`, `plan.rb`). +2. **`--write` only after the user said yes** to the Price Proposal shown in this conversation. An earlier + "yes" for another proposal or product does not count. `asc.rb` refuses POST/PATCH/DELETE without it. +3. **Never touch the key.** Do not read, print, copy or move the `.p8`. Never put it in a repository. +4. **Prices only.** Never change which territories a product is sold in. +5. **A 2xx is not proof.** After every write, GET the prices back and report only what that read confirms. +6. Keep working files (plan input, proposal, request bodies) in a run folder + `./territory-pricing///`; offer to add `territory-pricing/` to `.gitignore`. + +## Workflow + +### 1. Check the key (always first) + +```bash +bash "$SKILL_DIR/scripts/check_setup.sh" +``` + +Stop on any `FAIL` and walk the user through the fix it prints (`references/setup.md`). The key lives in +`~/.appstoreconnect/private_keys/AuthKey_.p8`, the Issuer ID in `~/.appstoreconnect/config.json`. +Continue only on `setup OK`. + +### 2. Pick the app and the product + +List apps with `asc.rb GET '/v1/apps?limit=200&fields[apps]=name,bundleId' --paginate` (filter by the bundle +id from the Xcode project if there is one), then list the app's products (app price, in-app purchases, +subscription groups). Commands are in the playbook. Ask which product to price. For subscriptions price +**the whole subscription group** with one start date. One product per `plan.rb` run. + +When the app sells several products that belong together (monthly and yearly subscriptions, a lifetime +unlock), price them as a **family**: the same strategy, base territory and endings for all of them, one start +date D (tomorrow or later), and the ladder check of step 4 before consent. A lifetime unlock is an in-app +purchase of type non-consumable; its period is `LIFETIME`. + +### 3. Choose the strategy and settings + +Ask the user (AskUserQuestion): + +| Strategy | Use when | +|---|---| +| `gni_bands` (default) | Sensible default: three income bands 1.0x / 0.6x / 0.3x | +| `ppp` | Smooth, defensible curve from World Bank price levels | +| `big_mac` | The user asks for it; state the Asia caveat (Japan, Singapore, Hong Kong come out too cheap) | +| `tiered` | The user has their own country groups (`--tiers FILE`) | +| `equalization` | Baseline only: Apple's own prices, for comparison | + +Ask in the same question round how the final price should look (price endings; details in +`references/strategies.md`): + +| Endings | Meaning | +|---|---| +| `charm` (recommended) | Customary endings of each currency: .99 or .49 for USD, EUR, GBP and most cent currencies, .90 for BRL and ILS, .98 for SGD, round numbers in JPY, KRW and INR-style currencies. The price may move up to 5% away from the strategy's target to land on one. | +| `none` | The Price Point nearest to the target, whatever its ending | + +Offer a different tolerance (default 5%) only if the user wants prettier or stricter prices +(`--endings-tolerance`). + +Also confirm the base territory (default USA) and the guard rails: clamp 30%..130% of the equalized price, +flag changes above 25%. Defaults are fine unless the user objects. + +### 4. Collect the data (read-only) and build the Price Proposal + +Follow the playbook to collect, per territory: current price and price point, equalized price, and the list +of price points. Reading the price points is about 700 requests: use `scripts/collect_points.rb` in short +batches (run it again until it prints `done`, report progress after each run). Never run one long command +or your own sequential loop: the shell kills a command after 10 minutes and everything collected is lost. +Save the result as `plan-input.json` with `product.period` set (the subscription's `subscriptionPeriod`, such +as `ONE_MONTH` or `ONE_YEAR`, or `LIFETIME` for a lifetime unlock), then: + +```bash +ruby "$SKILL_DIR/scripts/plan.rb" --input "$RUN/plan-input.json" --out "$RUN" --strategy --base --endings +``` + +It writes `proposal.json` and `proposal.csv` and prints the summary. Show the user: counts, data sources +with dates, largest decreases and increases, skipped territories, and the path to `proposal.csv`. Point +out flags (`clamped`, `large-change`) and territories that used a fallback (`ratio_source` other than the +chosen strategy). + +**Family check.** When you priced more than one product of the app, run the ladder check over all their +proposals: + +```bash +ruby "$SKILL_DIR/scripts/ladder.rb" "$RUN_A/proposal.json" "$RUN_B/proposal.json" "$RUN_C/proposal.json" +``` + +Per territory it requires that a longer subscription costs more in total but less per month, and that a +lifetime unlock costs at least 2x the cheapest yearly cost (`--lifetime-min-years` to change it). Show the +violations next to the proposals. It exits 1 on a violation, 2 when a proposal has no `product.period`. + +If the user edits `proposal.csv` (change `proposed`; empty the cell to leave a territory alone), run +`plan.rb --out "$RUN" --rebuild` and show the new summary. Prices like `9,90` or `$4.99` are understood; if a +cell cannot be read, `--rebuild` names it and changes nothing. A rebuild keeps the settings of the original +run. To try another strategy, run `plan.rb` again. + +### 5. Get consent + +Ask plainly: apply this proposal? Name the product, strategy, the number of territories that change and the +largest decrease. Wait for a clear yes. Anything else: adjust and re-run `plan.rb`. + +**Do not ask for consent while the ladder check reports violations.** Fix them (edit `proposal.csv`, run +`plan.rb --rebuild`, run `ladder.rb` again) or change the settings. Only if the user +explicitly accepts the listed violations, save their answer as `ladder-accepted.txt` in the run folder and go on. + +### 6. Write + +Follow the playbook's write section for the product kind, always with `--write` and always from +`proposal.json` (the rows with a `price_point_id` that differs from `current_point_id`): + +* **App price / in-app purchase:** one schedule with the base territory and every manual price, built by + `schedule_body.rb` (`--start-date D` makes changed territories switch on day D; without it they change at + once). For a family, write the subscriptions with start date D and the lifetime unlock with + `--start-date D`, so all of them switch on the same day. +* **Subscription:** price changes that took effect cannot be undone, so get a **second, separate yes** + with that warning, write 1-2 canary territories first, read them back and show the user, then the rest + with the **same** start date. Existing subscribers keep their price (`preserveCurrentPrice: true`) unless + the user explicitly asks otherwise. + +### 7. Verify and report + +GET the prices back and compare with `proposal.json`. Report the count that matched and every mismatch or +failure verbatim. For subscriptions the old price stays visible until the start date; that is a scheduled +change, not a failure. + +### Rollback + +`proposal.json` keeps each territory's `current_point_id`. Rolling back means writing those points again +(app/IAP: one schedule; subscriptions: cancel the still-scheduled `subscriptionPrices` first). A subscription +change that already took effect can only be superseded by a new change, not undone; say that first. + +## Gotchas that cost money + +* Posting a new base price alone wipes every per-territory price. Always send the base and all manual prices together. +* New subscription prices start the next day at the earliest. An in-app purchase written without a start date + changes at once, so a lifetime unlock written today is dearer or cheaper than the yearly plan for a day: + give it the same start date. +* A price in effect can sit on a **legacy price point** that Apple no longer lists; its equalizations + return 404. `plan.rb` maps it onto the listed point with the same price, and the playbook does the same + for the base point before reading equalizations. +* Territories with no data for the strategy fall back (Big Mac, then PPP, then equalization); the table + says which one was used. +* Economic data comes from the bundled snapshot (`references/data/`); the summary prints its date. Refresh + it with `ruby "$SKILL_DIR/scripts/refresh_data.rb"`. + +## References + +* `references/setup.md`: creating the API key and where it goes +* `references/strategies.md`: formulas, data sources, caveats, fallback chain +* `references/asc-pricing-api.md`: the playbook, endpoints per product kind, collecting data, writing, verifying diff --git a/skills/territory-pricing/references/asc-pricing-api.md b/skills/territory-pricing/references/asc-pricing-api.md new file mode 100644 index 0000000..c892a7d --- /dev/null +++ b/skills/territory-pricing/references/asc-pricing-api.md @@ -0,0 +1,177 @@ +# Playbook: App Store Connect pricing calls + +You call the API with `scripts/asc.rb`. Shapes follow Apple's App Store Connect API documentation; the +points marked **unverified** have not been confirmed against the live API. + +```bash +A="ruby $SKILL_DIR/scripts/asc.rb" +$A GET '/v1/apps?limit=1' # one request +$A GET '/v1/subscriptions/42/prices?limit=200' --paginate # follows links.next, merges data + included +$A POST /v1/subscriptionPrices @body.json --write # writes need --write and a JSON body or @file +``` + +Brackets in query strings must be percent-encoded: `filter%5Bterritory%5D=USA`. In Ruby, +`require "#{ENV['SKILL_DIR']}/scripts/asc"` gives you `Asc.request`, `Asc.paginate`, `Asc.path` (adds `&` when the +base already has a query) and `Asc.index_included` for a throwaway collection script (keep it in the scratchpad, not in the repo). + +## Endpoints + +| | App price | In-app purchase | Subscription | +|---|---|---|---| +| Current prices | `GET /v1/apps/{id}/appPriceSchedule`, then `/v1/appPriceSchedules/{sid}/manualPrices` and `/automaticPrices` (`include=appPricePoint,territory`) | `GET /v2/inAppPurchases/{id}/iapPriceSchedule`, then `/v1/inAppPurchasePriceSchedules/{sid}/manualPrices` and `/automaticPrices` | `GET /v1/subscriptions/{id}/prices?include=subscriptionPricePoint,territory` | +| Base territory | `/v1/appPriceSchedules/{sid}/baseTerritory` | `/v1/inAppPurchasePriceSchedules/{sid}/baseTerritory` | none; ask the user (default USA) | +| Price points of a territory | `GET /v1/apps/{id}/appPricePoints?filter[territory]=X` | `GET /v2/inAppPurchases/{id}/pricePoints?filter[territory]=X` | `GET /v1/subscriptions/{id}/pricePoints?filter[territory]=X` | +| Equalized prices of a base point | `GET /v3/appPricePoints/{pp}/equalizations?include=territory` | `GET /v1/inAppPurchasePricePoints/{pp}/equalizations` | `GET /v1/subscriptionPricePoints/{pp}/equalizations?include=territory` | +| Write | `POST /v1/appPriceSchedules` | `POST /v1/inAppPurchasePriceSchedules` | `POST /v1/subscriptionPrices`, one per territory | +| Products of an app | the app itself | `GET /v1/apps/{id}/inAppPurchasesV2` | `GET /v1/apps/{id}/subscriptionGroups`, then `/v1/subscriptionGroups/{gid}/subscriptions` | + +Each price point has `attributes.customerPrice` (a string) and a `territory` relationship; the territory's +`attributes.currency` comes with `include=territory`. A price resource links to its price point through the +relationship `appPricePoint` / `inAppPurchasePricePoint` / `subscriptionPricePoint`; resolve it in `included`. +A price whose `startDate` is in the future is a scheduled change, not the price in effect. + +## Collecting the data for `plan.rb` + +Build `plan-input.json` (format in `scripts/plan.rb`'s header): + +1. Read the current prices; per territory take the price in effect (no `startDate` or one in the past, and + no `endDate` in the past): `current` (a number), `current_point_id`, `currency`. +2. Read the price points of the base territory. **If `current_point_id` of the base is not in that list, + the price sits on a legacy point: use the listed point with the same price as the base point.** Calling + `equalizations` with a legacy id returns 404 ("does not correspond to a valid pricePoint or was + deprecated"). +3. Read the equalizations of that base point: `equalized` and `currency` per territory (the base territory + itself may be missing; use its current price). +4. For every territory read its price points: `points: [{"id", "price"}]` with `price` as a number. This is + the slow part, see "Reading price points in batches" below. Do not write your own sequential loop. +5. `available` is true unless you verified the territory is not sold. **Do not mark territories unavailable + from an empty or failed availability read**: an earlier version did exactly that and marked all 175 + territories unavailable. Availability endpoints (**unverified**): app + `/v1/apps/{id}/appAvailabilityV2` then `/v2/appAvailabilities/{id}/territoryAvailabilities`; IAP + `/v2/inAppPurchases/{id}/inAppPurchaseAvailability` then `.../availableTerritories`; subscription + `/v1/subscriptions/{id}/subscriptionAvailability` then `/v1/subscriptionAvailabilities/{id}/availableTerritories`. + If you cannot read availability reliably, leave `available` true and tell the user. + +Put `"product": {"kind", "id", "name", "period"}` and `"base_territory"` at the top of the file. `period` is what +`ladder.rb` compares: a subscription's `attributes.subscriptionPeriod` (`ONE_WEEK`, `ONE_MONTH`, `TWO_MONTHS`, +`THREE_MONTHS`, `SIX_MONTHS`, `ONE_YEAR`; the products list shows it) or `LIFETIME` for a lifetime unlock +(an in-app purchase with `inAppPurchaseType` `NON_CONSUMABLE`). + +### Reading price points in batches + +A subscription has about 800 price points per territory and Apple returns 200 per page, so the sweep is about +175 x 4 = 700 requests, roughly 12 minutes one after another. One long command is killed by the shell after +10 minutes and loses everything, so sweep once, in short resumable commands, and slice locally afterwards. + +1. Write the territory codes you need (the keys of the current prices) to `$RUN/territories.json`. +2. Run, repeating until it prints `done` (each run reads one batch of 40 territories in 6 parallel threads, + about 30 seconds, and saves every territory to `$RUN/points/.json`): + + ```bash + ruby "$SKILL_DIR/scripts/collect_points.rb" "$RUN" /v1/subscriptions//pricePoints + ``` + + Path per kind: `/v1/apps//appPricePoints`, `/v2/inAppPurchases//pricePoints`. +3. After every run tell the user the progress line (`80/175 territories saved`). If a run reports `failed:` + territories, run it again: finished territories are skipped, only the failed ones are retried. Persistent + failures (HTTP 401/403) are not retried by waiting; report them. +4. Build `plan-input.json` from the saved files with a short script, then call `plan.rb`. Never re-read the API + because the analysis changed; answer follow-ups from the files. + +Budget: Apple limits requests per key (about 3600 an hour, unverified), so a full sweep uses a noticeable part of +it. Reuse `$RUN/points/` when you re-run a plan for the same product on the same day; do not sweep twice. + +## Writing + +Only after consent, always with `--write`; save every request body in the run folder. + +**App and in-app purchase: one request, built by `schedule_body.rb`.** + +```bash +ruby "$SKILL_DIR/scripts/schedule_body.rb" --proposal "$RUN/proposal.json" --kind app --id [--start-date D] > "$RUN/body.json" +ruby "$SKILL_DIR/scripts/asc.rb" POST /v1/appPriceSchedules @"$RUN/body.json" --write +``` + +Use `--kind iap --id ` and `POST /v1/inAppPurchasePriceSchedules` for an in-app purchase. Look at +`body.json` before posting. It holds the base territory and a `manualPrices` entry for every territory that has a +`price_point_id` (changed or not). A territory `plan.rb` skipped (unavailable, no equalized price, no price points) +carries its **current** point in that field, so its manual price survives; only a territory with no current price at +all has none and falls back to Apple's automatic price. + +* **Without `--start-date`** every price takes effect at once. +* **With `--start-date D`** (after today, UTC) a territory whose price changes keeps its current point until D and + switches on D; unchanged territories keep one immediate price. App prices use `startDate` / `endDate` on the old + and new entry, in-app purchase prices a `startDate` pair, as in Apple's examples. **This dated form is not + verified against the live API**: try it on a non-critical in-app purchase first and read the schedule back. + +The body looks like this (an app, immediate; `schedule_body.rb` writes it for you): + +```json +{ + "data": { + "type": "appPriceSchedules", + "attributes": {}, + "relationships": { + "app": { "data": { "type": "apps", "id": "" } }, + "baseTerritory": { "data": { "type": "territories", "id": "USA" } }, + "manualPrices": { "data": [{ "type": "appPrices", "id": "${price0}" }] } + } + }, + "included": [ + { "type": "appPrices", "id": "${price0}", "attributes": { "startDate": null }, + "relationships": { "appPricePoint": { "data": { "type": "appPricePoints", "id": "" } } } } + ] +} +``` + +For an in-app purchase the names change: `inAppPurchasePriceSchedules`, relationship `inAppPurchase` (type +`inAppPurchases`), prices `inAppPurchasePrices` with the price point relationship `inAppPurchasePricePoint`, and each +price also names its product in `inAppPurchaseV2`. Never post the base alone: that drops every per-territory price +back to Apple's automatic one. + +**Subscription: one request per territory.** + +```json +{ "data": { "type": "subscriptionPrices", + "attributes": { "startDate": "YYYY-MM-DD", "preserveCurrentPrice": true }, + "relationships": { + "subscription": { "data": { "type": "subscriptions", "id": "" } }, + "subscriptionPricePoint": { "data": { "type": "subscriptionPricePoints", "id": "" } } } } } +``` + +Order: (1) second explicit yes with the irreversibility warning; (2) `startDate` = tomorrow or later, the +**same** for every subscription of the group; (3) canary on 1-2 territories, GET them back, show the user; +(4) the rest. Only territories whose `price_point_id` differs from `current_point_id`. Keep a log of what you +wrote so a rerun resumes. `preserveCurrentPrice: true` keeps existing subscribers on their price; `false` +triggers Apple's price-increase consent flow and only on the user's explicit request. + +## A family of products (subscriptions plus a lifetime unlock) + +Price the products with the same strategy, base territory and endings, one `plan.rb` run each, then check the +ladder across all their proposals with `ladder.rb` (details in `SKILL.md`, step 4); consent is blocked while it +reports violations. Write them so they all switch on one day D (tomorrow or later): + +1. each subscription: `startDate` D, canary, then the rest; +2. the lifetime unlock: `schedule_body.rb ... --kind iap --start-date D`. + +Without the shared date an in-app purchase written today changes at once while the subscriptions change tomorrow, so +for a day the yearly plan could cost more than the lifetime unlock. + +## Verifying + +GET the prices back with the same calls as in "Collecting" and compare the price point in effect (for +subscriptions: the scheduled entry with the same `startDate`; for an app or in-app purchase written with +`--start-date`: the entry with that `startDate` in the schedule's `manualPrices`) with `proposal.json`. Report counts +and every mismatch. + +## Rollback + +`proposal.json` rows carry `current_point_id` (resolved to a listed point). App/IAP: write one schedule with +those points. Subscriptions: first `DELETE /v1/subscriptionPrices/{id} --write` for each still-scheduled +change you created (**unverified** endpoint), then write the old points as a new change only where the price in +effect differs. Never delete scheduled changes that existed before your run. + +## Rate limits and errors + +`asc.rb` retries 429 and 5xx with backoff. `401`: key id, issuer id and `.p8` do not belong together. +`403`: key role too low (needs App Manager or Admin). `404` on a price point: legacy point, see above. diff --git a/skills/territory-pricing/references/data/big-mac.csv b/skills/territory-pricing/references/data/big-mac.csv new file mode 100644 index 0000000..702ed24 --- /dev/null +++ b/skills/territory-pricing/references/data/big-mac.csv @@ -0,0 +1,76 @@ +iso_a3,value,as_of +ARE,5.173094,2026-07-01 +ARG,5.90637,2026-07-01 +AUS,5.949575,2026-07-01 +AUT,7.079221,2026-07-01 +AZE,4.264706,2026-07-01 +BEL,7.079221,2026-07-01 +BGR,7.079221,2026-07-01 +BHR,4.773903,2026-07-01 +BRA,4.705326,2026-07-01 +CAN,5.814326,2026-07-01 +CHE,9.041927,2026-07-01 +CHL,5.407954,2026-07-01 +CHN,3.913865,2026-07-01 +COL,8.003016,2026-07-01 +CRI,6.995647,2026-07-01 +CYP,7.079221,2026-07-01 +CZE,5.434141,2026-07-01 +DEU,7.079221,2026-07-01 +DNK,7.037298,2026-07-01 +EGY,2.869582,2026-07-01 +ESP,7.079221,2026-07-01 +EST,7.079221,2026-07-01 +FIN,7.079221,2026-07-01 +FRA,7.079221,2026-07-01 +GBR,7.400218,2026-07-01 +GRC,7.079221,2026-07-01 +GTM,4.462001,2026-07-01 +HKG,3.252987,2026-07-01 +HND,5.19238,2026-07-01 +HRV,7.079221,2026-07-01 +HUN,5.297557,2026-07-01 +IDN,2.380293,2026-07-01 +IND,2.454195,2026-07-01 +IRL,7.079221,2026-07-01 +ISR,7.668456,2026-07-01 +ITA,7.079221,2026-07-01 +JOR,3.526093,2026-07-01 +JPN,3.08385,2026-07-01 +KOR,3.836059,2026-07-01 +KWT,4.523425,2026-07-01 +LBN,5.360134,2026-07-01 +LKA,5.688389,2026-07-01 +LTU,7.079221,2026-07-01 +LUX,7.079221,2026-07-01 +LVA,7.079221,2026-07-01 +MDA,4.558405,2026-07-01 +MEX,6.269412,2026-07-01 +MLT,7.079221,2026-07-01 +MYS,3.567925,2026-07-01 +NIC,5.1059,2026-07-01 +NLD,7.079221,2026-07-01 +NOR,8.053151,2026-07-01 +NZL,5.13612,2026-07-01 +OMN,3.97351,2026-07-01 +PAK,3.88594,2026-07-01 +PER,4.991582,2026-07-01 +PHL,2.739726,2026-07-01 +POL,6.21833,2026-07-01 +PRT,7.079221,2026-07-01 +QAT,4.943697,2026-07-01 +ROU,4.005107,2026-07-01 +SAU,5.05992,2026-07-01 +SGP,5.77497,2026-07-01 +SVK,7.079221,2026-07-01 +SVN,7.079221,2026-07-01 +SWE,7.161428,2026-07-01 +THA,4.015467,2026-07-01 +TUR,6.91085,2026-07-01 +TWN,2.423301,2026-07-01 +UKR,3.335557,2026-07-01 +URY,8.93813,2026-07-01 +USA,6.22,2026-07-01 +VEN,4.507203,2026-07-01 +VNM,2.894632,2026-07-01 +ZAF,3.485452,2026-07-01 diff --git a/skills/territory-pricing/references/data/endings.json b/skills/territory-pricing/references/data/endings.json new file mode 100644 index 0000000..4528edb --- /dev/null +++ b/skills/territory-pricing/references/data/endings.json @@ -0,0 +1,74 @@ +{ + "note": "Customary price endings per currency, used by plan.rb --endings charm. A price matches when (price * unit).round % mod is one of endings. Currencies not listed use .99/.49 (cent currencies) or round numbers of at most two significant digits (whole-number currencies). Edit freely or pass another file with --endings-file.", + "currencies": { + "BRL": { + "unit": 100, + "mod": 100, + "endings": [ + 90, + 99 + ] + }, + "CHF": { + "unit": 100, + "mod": 100, + "endings": [ + 90, + 0 + ] + }, + "JPY": { + "unit": 1, + "mod": 100, + "endings": [ + 80, + 0 + ] + }, + "KRW": { + "unit": 1, + "mod": 1000, + "endings": [ + 900, + 0 + ] + }, + "INR": { + "unit": 1, + "mod": 100, + "endings": [ + 99, + 49 + ] + }, + "MXN": { + "unit": 1, + "mod": 10, + "endings": [ + 9 + ] + }, + "ILS": { + "unit": 100, + "mod": 100, + "endings": [ + 90 + ] + }, + "SGD": { + "unit": 100, + "mod": 100, + "endings": [ + 98, + 48 + ] + }, + "CNY": { + "unit": 1, + "mod": 10, + "endings": [ + 8 + ] + } + } +} diff --git a/skills/territory-pricing/references/data/gni-bands.json b/skills/territory-pricing/references/data/gni-bands.json new file mode 100644 index 0000000..33a4e10 --- /dev/null +++ b/skills/territory-pricing/references/data/gni-bands.json @@ -0,0 +1,4 @@ +{ + "band1_overrides": ["ARE", "SAU", "QAT", "KWT", "BHR", "OMN", "JPN", "KOR", "SGP", "HKG", "TWN"], + "note": "Territories forced into band 1 (full price) regardless of GNI per capita: Gulf states plus rich Asian markets that a raw GNI cut or a Big Mac ratio would underprice." +} diff --git a/skills/territory-pricing/references/data/gni.csv b/skills/territory-pricing/references/data/gni.csv new file mode 100644 index 0000000..c8d77ff --- /dev/null +++ b/skills/territory-pricing/references/data/gni.csv @@ -0,0 +1,203 @@ +iso_a3,value,as_of +ABW,35200.0,2025 +AFG,390.0,2025 +AGO,2860.0,2025 +ALB,12060.0,2025 +AND,53230.0,2025 +ARE,51550.0,2025 +ARG,14650.0,2025 +ARM,9020.0,2025 +ATG,23790.0,2025 +AUS,64120.0,2025 +AUT,60360.0,2025 +AZE,7360.0,2025 +BDI,240.0,2025 +BEL,59500.0,2025 +BEN,1600.0,2025 +BFA,980.0,2025 +BGD,2840.0,2025 +BGR,17780.0,2025 +BHR,28790.0,2025 +BHS,37020.0,2025 +BIH,9940.0,2025 +BLR,9160.0,2025 +BLZ,7530.0,2025 +BMU,139370.0,2025 +BOL,4420.0,2025 +BRA,10550.0,2025 +BRB,27080.0,2025 +BRN,34790.0,2025 +BTN,4310.0,2025 +BWA,7390.0,2025 +CAF,560.0,2025 +CAN,56420.0,2025 +CHE,110330.0,2025 +CHL,16960.0,2025 +CHN,14230.0,2025 +CIV,2780.0,2025 +CMR,1860.0,2025 +COD,720.0,2025 +COG,2280.0,2025 +COL,7900.0,2025 +COM,1950.0,2025 +CPV,5590.0,2025 +CRI,17930.0,2025 +CUB,9010.0,2025 +CUW,22570.0,2025 +CYM,81920.0,2025 +CYP,36110.0,2025 +CZE,32960.0,2025 +DEU,60200.0,2025 +DJI,3960.0,2025 +DMA,10690.0,2025 +DNK,77190.0,2025 +DOM,10620.0,2025 +DZA,5850.0,2025 +ECU,6890.0,2025 +EGY,3260.0,2025 +ESP,37120.0,2025 +EST,32310.0,2025 +ETH,1110.0,2025 +FIN,55250.0,2025 +FJI,6230.0,2025 +FRA,48630.0,2025 +FRO,73120.0,2025 +FSM,4760.0,2025 +GAB,8090.0,2025 +GBR,54550.0,2025 +GEO,8990.0,2025 +GHA,2630.0,2025 +GIN,1730.0,2025 +GMB,930.0,2025 +GNB,1090.0,2025 +GNQ,5890.0,2025 +GRC,25360.0,2025 +GRD,11660.0,2025 +GTM,6360.0,2025 +GUY,28470.0,2025 +HKG,62500.0,2025 +HND,3270.0,2025 +HRV,25360.0,2025 +HTI,2010.0,2025 +HUN,23850.0,2025 +IDN,5120.0,2025 +IMN,87030.0,2025 +IND,2760.0,2025 +IRL,87360.0,2025 +IRN,4650.0,2025 +IRQ,5690.0,2025 +ISL,89220.0,2025 +ISR,56180.0,2025 +ITA,42080.0,2025 +JAM,7790.0,2025 +JOR,5260.0,2025 +JPN,38340.0,2025 +KAZ,13740.0,2025 +KEN,2200.0,2025 +KGZ,2800.0,2025 +KHM,2750.0,2025 +KIR,3930.0,2025 +KNA,24530.0,2025 +KOR,37880.0,2025 +KWT,41110.0,2025 +LAO,2150.0,2025 +LBN,3560.0,2025 +LBR,830.0,2025 +LBY,7250.0,2025 +LCA,13410.0,2025 +LKA,4670.0,2025 +LSO,1280.0,2025 +LTU,30500.0,2025 +LUX,95720.0,2025 +LVA,24980.0,2025 +MAC,69920.0,2025 +MAR,4360.0,2025 +MDA,8050.0,2025 +MDG,560.0,2025 +MDV,12950.0,2025 +MEX,13730.0,2025 +MHL,9710.0,2025 +MKD,9490.0,2025 +MLI,1120.0,2025 +MLT,41440.0,2025 +MMR,1320.0,2025 +MNE,14150.0,2025 +MNG,6210.0,2025 +MOZ,570.0,2025 +MRT,2210.0,2025 +MUS,14040.0,2025 +MWI,600.0,2025 +MYS,12380.0,2025 +NAM,4340.0,2025 +NCL,30070.0,2025 +NER,750.0,2025 +NGA,1360.0,2025 +NIC,2850.0,2025 +NLD,68530.0,2025 +NOR,97310.0,2025 +NPL,1570.0,2025 +NRU,20690.0,2025 +NZL,46630.0,2025 +OMN,19520.0,2025 +PAK,1500.0,2025 +PAN,19140.0,2025 +PER,8430.0,2025 +PHL,4850.0,2025 +PLW,19890.0,2025 +PNG,2890.0,2025 +POL,25520.0,2025 +PRI,27320.0,2025 +PRT,29930.0,2025 +PRY,6750.0,2025 +PSE,3250.0,2025 +PYF,24100.0,2025 +QAT,74330.0,2025 +ROU,20190.0,2025 +RUS,15960.0,2025 +RWA,1150.0,2025 +SAU,36070.0,2025 +SDN,900.0,2025 +SEN,1780.0,2025 +SGP,81760.0,2025 +SLB,2020.0,2025 +SLE,830.0,2025 +SLV,5410.0,2025 +SMR,53910.0,2025 +SOM,640.0,2025 +SRB,13480.0,2025 +STP,3800.0,2025 +SUR,6140.0,2025 +SVK,26410.0,2025 +SVN,35520.0,2025 +SWE,63010.0,2025 +SWZ,3730.0,2025 +SXM,38950.0,2025 +SYC,19200.0,2025 +SYR,720.0,2025 +TCA,36760.0,2025 +TCD,970.0,2025 +TGO,1350.0,2025 +THA,7690.0,2025 +TJK,2080.0,2025 +TKM,6340.0,2025 +TLS,1510.0,2025 +TON,6840.0,2025 +TTO,18550.0,2025 +TUN,4300.0,2025 +TUR,16300.0,2025 +TUV,9780.0,2025 +TZA,1270.0,2025 +UGA,1120.0,2025 +UKR,5510.0,2025 +URY,24020.0,2025 +USA,88810.0,2025 +UZB,3670.0,2025 +VCT,12000.0,2025 +VEN,3860.0,2025 +VNM,4970.0,2025 +VUT,4410.0,2025 +WSM,5640.0,2025 +XKX,7760.0,2025 +ZAF,6270.0,2025 +ZMB,1200.0,2025 +ZWE,2660.0,2025 diff --git a/skills/territory-pricing/references/data/ppp.csv b/skills/territory-pricing/references/data/ppp.csv new file mode 100644 index 0000000..7fe4bbe --- /dev/null +++ b/skills/territory-pricing/references/data/ppp.csv @@ -0,0 +1,204 @@ +iso_a3,value,as_of +ABW,0.845499,2025 +AFG,0.205526,2025 +AGO,0.399238,2025 +ALB,0.54281,2025 +AND,0.755958,2025 +ARE,0.688084,2025 +ARG,0.452531,2025 +ARM,0.409627,2025 +ASM,0.903446,2025 +ATG,0.81166,2025 +AUS,0.943682,2025 +AUT,0.847885,2025 +AZE,0.31938,2025 +BDI,0.456873,2025 +BEL,0.872074,2025 +BEN,0.337122,2025 +BFA,0.33184,2025 +BGD,0.286585,2025 +BGR,0.238946,2025 +BHR,0.487929,2025 +BHS,1.089629,2025 +BIH,0.443008,2025 +BLR,0.276493,2025 +BLZ,0.54381,2025 +BMU,1.441351,2025 +BOL,0.390627,2025 +BRA,0.462029,2025 +BRB,1.31865,2025 +BRN,0.403059,2025 +BTN,0.241541,2025 +BWA,0.393318,2025 +CAF,0.445097,2025 +CAN,0.901565,2025 +CHE,1.281694,2025 +CHL,0.511787,2025 +CHN,0.481173,2025 +CIV,0.392242,2025 +CMR,0.359165,2025 +COD,0.466777,2025 +COG,0.397412,2025 +COL,0.410939,2025 +COM,0.582788,2025 +CPV,0.541364,2025 +CRI,0.624708,2025 +CUW,0.787179,2025 +CYM,1.401008,2025 +CYP,0.669234,2025 +CZE,0.669852,2025 +DEU,0.812326,2025 +DJI,0.498245,2025 +DMA,0.577021,2025 +DNK,1.04528,2025 +DOM,0.384091,2025 +DZA,0.31713,2025 +ECU,0.441506,2025 +EGY,0.15686,2025 +ERI,0.344722,2025 +ESP,0.686867,2025 +EST,0.759056,2025 +ETH,0.296532,2025 +FIN,0.906258,2025 +FJI,0.38688,2025 +FRA,0.827714,2025 +FRO,1.03898,2025 +FSM,0.926676,2025 +GAB,0.482619,2025 +GBR,0.923704,2025 +GEO,0.396513,2025 +GHA,0.418096,2025 +GIN,0.322625,2025 +GMB,0.286557,2025 +GNB,0.386394,2025 +GNQ,0.454657,2025 +GRC,0.655452,2025 +GRD,0.617765,2025 +GRL,0.872576,2025 +GTM,0.446891,2025 +GUM,1.112134,2025 +GUY,0.469089,2025 +HKG,0.739711,2025 +HND,0.486154,2025 +HRV,0.588469,2025 +HTI,1.022248,2025 +HUN,0.579206,2025 +IDN,0.307441,2025 +IND,0.227619,2025 +IRL,1.021463,2025 +IRN,2.109362,2025 +IRQ,0.41999,2025 +ISL,1.29708,2025 +ISR,1.05906,2025 +ITA,0.72864,2025 +JAM,0.617498,2025 +JOR,0.448294,2025 +JPN,0.690485,2025 +KAZ,0.366342,2025 +KEN,0.3479,2025 +KGZ,0.335642,2025 +KHM,0.366968,2025 +KIR,0.624437,2025 +KNA,0.794858,2025 +KOR,0.618743,2025 +KWT,0.597763,2025 +LAO,0.287129,2025 +LBN,0.653298,2025 +LBR,0.002644,2025 +LBY,0.338041,2025 +LCA,0.590632,2025 +LKA,0.269675,2025 +LSO,0.348429,2025 +LTU,0.621064,2025 +LUX,0.986478,2025 +LVA,0.623892,2025 +MAC,0.616062,2025 +MAR,0.423479,2025 +MDA,0.504835,2025 +MDG,0.290388,2025 +MDV,0.621073,2025 +MEX,0.58785,2025 +MHL,0.952069,2025 +MKD,0.409707,2025 +MLI,0.332427,2025 +MLT,0.689699,2025 +MMR,0.315216,2025 +MNE,0.494788,2025 +MNG,0.333054,2025 +MNP,0.937483,2025 +MOZ,0.392627,2025 +MRT,0.33943,2025 +MUS,0.437105,2025 +MWI,0.336569,2025 +MYS,0.333915,2025 +NAM,0.412504,2025 +NCL,1.150766,2025 +NER,0.342963,2025 +NGA,0.209473,2025 +NIC,0.36953,2025 +NLD,0.867086,2025 +NOR,0.962051,2025 +NPL,0.253533,2025 +NRU,0.858947,2025 +NZL,0.888948,2025 +OMN,0.47299,2025 +PAK,0.232608,2025 +PAN,0.496706,2025 +PER,0.540229,2025 +PHL,0.357074,2025 +PLW,0.963318,2025 +PNG,0.669922,2025 +POL,0.548633,2025 +PRI,0.896932,2025 +PRT,0.649416,2025 +PRY,0.380966,2025 +PYF,1.009768,2025 +QAT,0.757002,2025 +ROU,0.487757,2025 +RUS,0.36763,2025 +RWA,0.260913,2025 +SAU,0.498863,2025 +SDN,0.410206,2025 +SEN,0.389248,2025 +SGP,0.783062,2025 +SLB,0.918446,2025 +SLE,0.248573,2025 +SLV,0.468846,2025 +SMR,0.829608,2025 +SRB,0.50906,2025 +SSD,0.515912,2025 +STP,0.675966,2025 +SUR,0.419471,2025 +SVK,0.639286,2025 +SVN,0.669859,2025 +SWE,0.905129,2025 +SWZ,0.418646,2025 +SXM,0.858715,2025 +SYC,0.573856,2025 +SYR,0.403554,2025 +TCA,1.317191,2025 +TCD,0.382798,2025 +TGO,0.375288,2025 +THA,0.322547,2025 +TJK,0.319254,2025 +TLS,0.445648,2025 +TON,0.778612,2025 +TTO,0.569114,2025 +TUN,0.327055,2025 +TUR,0.449739,2025 +TUV,0.972346,2025 +TZA,0.253078,2025 +UGA,0.371111,2025 +UKR,0.284598,2025 +URY,0.724798,2025 +USA,1.0,2025 +UZB,0.284058,2025 +VCT,0.621522,2025 +VIR,1.01028,2025 +VNM,0.296327,2025 +VUT,1.015906,2025 +WSM,0.674081,2025 +XKX,0.406165,2025 +ZAF,0.432672,2025 +ZMB,0.33204,2025 +ZWE,0.000327,2025 diff --git a/skills/territory-pricing/references/setup.md b/skills/territory-pricing/references/setup.md new file mode 100644 index 0000000..af208b4 --- /dev/null +++ b/skills/territory-pricing/references/setup.md @@ -0,0 +1,56 @@ +# Setup: App Store Connect API key + +The scripts talk to the App Store Connect API with a team API key. The key is created once and the +`.p8` file can be downloaded **only once**. + +## 1. Create the key + +App Store Connect -> Users and Access -> Integrations -> App Store Connect API -> Team Keys -> +**Generate API Key**. + +* Access: **App Manager** or **Admin**. Lower roles can read apps but cannot change prices (HTTP 403). +* Download `AuthKey_.p8` and note the **Issuer ID** (UUID at the top of the Team Keys page). + +## 2. Put it where the scripts look + +``` +~/.appstoreconnect/ +├── config.json {"issuer_id": ""} +└── private_keys/ + └── AuthKey_.p8 +``` + +```bash +mkdir -p ~/.appstoreconnect/private_keys +mv ~/Downloads/AuthKey_.p8 ~/.appstoreconnect/private_keys/ +echo '{"issuer_id": ""}' > ~/.appstoreconnect/config.json +chmod 700 ~/.appstoreconnect ~/.appstoreconnect/private_keys +chmod 600 ~/.appstoreconnect/config.json ~/.appstoreconnect/private_keys/*.p8 +``` + +This is the same folder Apple's `altool` and fastlane use, so one key serves all of them. The Key ID is +read from the file name. With several keys in the folder, set `ASC_KEY_ID` to pick one. + +Environment variables override the folder: `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_P8` (path to the key). + +## 3. Verify + +```bash +bash scripts/check_setup.sh +``` + +| Line | Meaning | +|---|---| +| `FAIL no AuthKey_...` | key missing or in the wrong folder | +| `FAIL ... not a valid EC key` / `curve` | not the downloaded `.p8`, or not a P-256 key | +| `FAIL issuer id missing` | `config.json` missing or empty | +| `WARN permissions` | run the `chmod` it prints | +| `FAIL HTTP 401` | key id, issuer id and `.p8` do not belong together | +| `FAIL HTTP 403` | key role too low, regenerate with App Manager or Admin | + +## Security + +* The key stays outside every repository. `check_setup.sh` warns when the folder is inside a git work tree. +* Scripts never print key material. Do not paste the `.p8` into a chat; if it leaked, revoke the key in + App Store Connect and generate a new one. +* `scripts/asc.rb` only reads unless you pass `--write`; the skill adds that flag only after the user approved the Price Proposal. diff --git a/skills/territory-pricing/references/strategies.md b/skills/territory-pricing/references/strategies.md new file mode 100644 index 0000000..0309fff --- /dev/null +++ b/skills/territory-pricing/references/strategies.md @@ -0,0 +1,126 @@ +# Pricing strategies + +Every strategy produces a **ratio** per territory. The target price is + +``` +target = equalized_local_price x ratio (ratio clamped to [clamp_min, clamp_max], default 0.30 .. 1.30) +proposed = the nearest price point Apple offers in that territory (ties go to the cheaper one) +``` + +The ratio multiplies Apple's *already equalized local* price, so a 0.3 ratio lands at 30% of what Apple +would charge there, not 30% of the US price in dollars. The base territory always keeps its price. + +## Fallback chain + +When a territory has no data for the chosen strategy, the next source is used and the table's +`ratio_source` column says which one: + +``` +big_mac -> ppp -> equalization (ratio 1.0) +ppp -> equalization +gni_bands -> equalization +tiered -> default tier +``` + +## gni_bands (default) + +Three bands on GNI per capita (World Bank Atlas method, `NY.GNP.PCAP.CD`): + +| Band | GNI per capita | Ratio | +|---|---|---| +| 1 | >= 14,000 USD | 1.0 | +| 2 | 4,500 .. 14,000 USD | 0.6 | +| 3 | < 4,500 USD | 0.3 | + +Band-1 overrides regardless of income (`references/data/gni-bands.json`): the Gulf states, Japan, South +Korea, Singapore, Hong Kong, Taiwan. Cut points and ratios are options (`band1_min_gni`, `band2_min_gni`, +`band_ratios`). Simple to explain and to defend; steps rather than a smooth curve. + +## ppp + +Price level of each country relative to the base: PPP conversion factor for private consumption divided by +the market exchange rate (`PA.NUS.PRVT.PP / PA.NUS.FCRF`, latest year both exist). Brazil is about 0.46, +Japan about 0.69, India about 0.23 (US = 1.0). Smooth and covers about 200 countries. Reflects what a basket of +goods costs, not willingness to pay for software, so the clamp matters. + +## big_mac + +The Economist's Big Mac index: dollar price of a Big Mac in the territory divided by the base's. +Covers about 75 countries (the euro-area price is spread over its members); rows older than three years are +dropped. Popular and easy to explain, but a non-tradable food basket **underprices rich Asian markets**: +at the time of writing Japan comes out near 0.5 while PPP says 0.69. Apply it if the user wants it and say +this. Territories without a Big Mac price use PPP. + +## tiered + +The user's own groups. `--tiers FILE`: + +```json +{ + "tier_ratios": { "1": 1.0, "2": 0.7, "3": 0.4 }, + "default_tier": 1, + "territories": { "BRA": 2, "MEX": 2, "IND": 3, "IDN": 3 } +} +``` + +Unlisted territories get `default_tier`. `tier_ratios` and `default_tier` are optional (defaults 1.0 / 0.7 / 0.4, tier 1). + +## equalization + +Ratio 1.0 everywhere: Apple's automatic prices. Use it as a baseline to see what a strategy changes. + +## Price endings (`--endings`) + +The strategy gives a target price; Apple only sells fixed price points, and many of them look random +(a territory has about 800). With `--endings none` the nearest point wins, whatever its ending. With +`--endings charm` the nearest point **with a customary ending for the currency** wins, looked for within +`--endings-tolerance` (default 5%) around the target; if there is none, the nearest point is used and the +row's `note` says `no charm ending within 5%`. The base territory keeps its price. + +Customary endings are data, not code: `references/data/endings.json`. A price matches when +`(price * unit).round % mod` is one of `endings`. + +| Currency | Endings | Why | +|---|---|---| +| USD, EUR, GBP, CAD, AUD, PLN, TRY and other cent currencies without an entry | .99, .49 | the default | +| BRL, ILS | .90 | local convention (460 of 800 BRL price points end in .90) | +| CHF | .90, .00 | | +| SGD | .98, .48 | Apple's SGD points use .98 | +| JPY | ...80, ...00 | | +| KRW | ...900, ...000 | | +| INR | ...99, ...49 | | +| MXN | ending in 9 | | +| CNY | ending in 8 | | +| whole-number currency without an entry (IDR, VND, CLP, ...) | round numbers, at most two significant digits | | + +These conventions were checked against the price points of one real subscription for the currencies that +have an entry, not for all 175 territories. Review the table for the markets that matter to the user and edit +it, or pass another file with `--endings-file`. + +On the same data (175 territories, `gni_bands`), `charm` picked a different price than `none` in 81 territories, +none further than 4.8% from its target, and every territory found a customary price. Example: a 359.7 INR target +becomes 349 instead of 360, a 3.60 USD target becomes 3.49 instead of 3.59. + +The clamp bounds the ratio, not the final price: a charm price may sit up to the tolerance past the +clamp bounds. + +## Options (`plan.rb`) + +| Flag | Default | Meaning | +|---|---|---| +| `--strategy` | `gni_bands` | one of the strategies above | +| `--base` | `base_territory` of the input, else USA | anchor territory | +| `--clamp-min` / `--clamp-max` | 0.30 / 1.30 | ratio bounds against the equalized price | +| `--flag-threshold` | 0.25 | flag `large-change` when the new price differs from the current by more than this | +| `--tiers FILE` | none | JSON for `--strategy tiered` | +| `--endings` | `none` | `charm` for customary price endings, see above | +| `--endings-tolerance` | 0.05 | how far from the target a charm price may be | +| `--endings-file` | `references/data/endings.json` | ending rules per currency | + +## Data + +`plan.rb` reads only the bundled snapshots in `references/data/` (`big-mac.csv`, `ppp.csv`, `gni.csv`, +`gni-bands.json`) and prints each one's date in the summary. Sources: The Economist big-mac-data (GitHub) and +the World Bank Indicators API. Refresh them with `ruby scripts/refresh_data.rb`. Territory codes are ISO 3166 +alpha-3, as in App Store Connect; a territory whose code the sources do not know (for example a disputed or +unlisted region) falls back to equalization. diff --git a/skills/territory-pricing/scripts/asc.rb b/skills/territory-pricing/scripts/asc.rb new file mode 100755 index 0000000..b59a3d1 --- /dev/null +++ b/skills/territory-pricing/scripts/asc.rb @@ -0,0 +1,251 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# App Store Connect client: ES256 JWT, Ruby stdlib only. Never prints key material. +# +# ruby asc.rb --credentials resolve and validate the key +# ruby asc.rb GET '/v1/apps?limit=1' [--paginate] --paginate follows links.next and merges pages +# ruby asc.rb POST|PATCH|DELETE [json | @file] --write +# +# Anything but GET needs --write. Add it only after the user approved the +# Price Proposal; without it the script refuses before touching the network. +require 'openssl' +require 'base64' +require 'json' +require 'net/http' +require 'uri' + +module Asc + API_HOST = 'api.appstoreconnect.apple.com' + HOME_DIR = File.expand_path('~/.appstoreconnect') + KEY_DIR = File.join(HOME_DIR, 'private_keys') + CONFIG_PATH = File.join(HOME_DIR, 'config.json') + KEY_NAME = /\AAuthKey_([A-Z0-9]+)\.p8\z/.freeze + MAX_ATTEMPTS = 4 + + class Error < StandardError; end + + class HttpError < Error + attr_reader :code, :body + + def initialize(code, body) + @code = code + @body = body + super("HTTP #{code}: #{detail(body)}") + end + + private + + def detail(raw) + err = JSON.parse(raw.to_s)['errors']&.first + err ? [err['title'], err['detail']].compact.join(' - ') : raw.to_s[0, 200] + rescue JSON::ParserError + raw.to_s[0, 200] + end + end + + Credentials = Struct.new(:key_id, :issuer_id, :p8_path) + # collect_points.rb calls the client from several threads + CREDENTIALS_LOCK = Mutex.new + TOKEN_LOCK = Mutex.new + + module_function + + # Env vars (ASC_KEY_ID, ASC_ISSUER_ID, ASC_P8) win over the key folder. + def credentials + CREDENTIALS_LOCK.synchronize { @credentials ||= resolve_credentials } + end + + def resolve_credentials + key_id = ENV['ASC_KEY_ID'] + p8 = ENV['ASC_P8'] ? File.expand_path(ENV['ASC_P8']) : locate_key(key_id) + raise Error, "private key not found at #{p8}" unless File.file?(p8) + + key_id ||= File.basename(p8)[KEY_NAME, 1] + raise Error, 'cannot read the key id from the file name; set ASC_KEY_ID' unless key_id + + issuer = ENV['ASC_ISSUER_ID'] || read_config['issuer_id'] + raise Error, "issuer id missing: put {\"issuer_id\": \"\"} in #{CONFIG_PATH} or set ASC_ISSUER_ID" if issuer.to_s.empty? + + Credentials.new(key_id, issuer, p8) + end + + def locate_key(key_id) + return File.join(KEY_DIR, "AuthKey_#{key_id}.p8") if key_id + + keys = Dir.glob(File.join(KEY_DIR, 'AuthKey_*.p8')).sort + raise Error, "no AuthKey_.p8 found in #{KEY_DIR}" if keys.empty? + raise Error, "several keys in #{KEY_DIR}; set ASC_KEY_ID to pick one" if keys.size > 1 + + keys.first + end + + def read_config + return {} unless File.file?(CONFIG_PATH) + + JSON.parse(File.read(CONFIG_PATH)) + rescue JSON::ParserError + raise Error, "#{CONFIG_PATH} is not valid JSON" + end + + def private_key(path) + key = OpenSSL::PKey::EC.new(File.read(path)) + raise Error, 'key is public-only' unless key.private_key? + raise Error, "key curve is #{key.group.curve_name}, expected prime256v1 (P-256)" unless key.group.curve_name == 'prime256v1' + + key + rescue OpenSSL::PKey::PKeyError => e + raise Error, "private key is not a valid EC key (#{e.class})" + end + + def b64(data) + Base64.urlsafe_encode64(data, padding: false) + end + + def jwt(creds = credentials, now = Time.now.to_i) + header = { alg: 'ES256', kid: creds.key_id, typ: 'JWT' } + payload = { iss: creds.issuer_id, iat: now, exp: now + 1200, aud: 'appstoreconnect-v1' } + input = "#{b64(JSON.generate(header))}.#{b64(JSON.generate(payload))}" + der = private_key(creds.p8_path).sign(OpenSSL::Digest::SHA256.new, input) + # DER -> raw r||s (64 bytes), as JOSE ES256 requires + asn = OpenSSL::ASN1.decode(der) + raw = asn.value.map { |n| n.value.to_s(2).rjust(32, "\x00") }.join + "#{input}.#{b64(raw)}" + end + + def token + TOKEN_LOCK.synchronize do + now = Time.now.to_i + if @token.nil? || now > @token_at + 900 + fresh = jwt(credentials, now) + @token_at = now + @token = fresh + end + @token + end + end + + def enc(value) + URI.encode_www_form_component(value.to_s) + end + + # path('/v1/x', 'filter[territory]' => 'USA') -> '/v1/x?filter%5Bterritory%5D=USA' + def path(base, params = {}) + return base if params.empty? + + "#{base}#{base.include?('?') ? '&' : '?'}#{params.map { |k, v| "#{enc(k)}=#{enc(v)}" }.join('&')}" + end + + def request(method, target, body = nil) + uri = URI(target.start_with?('http') ? target : "https://#{API_HOST}#{target}") + raise Error, "refusing to send the token to #{uri.host}" unless uri.host == API_HOST + + attempts = 0 + loop do + attempts += 1 + res = http_call(uri, method, body) + code = res.code.to_i + if (code == 429 || code >= 500) && attempts < MAX_ATTEMPTS + sleep((res['Retry-After'] || (2**attempts)).to_f) + next + end + raise HttpError.new(code, res.body) unless code.between?(200, 299) + + return nil if res.body.to_s.empty? + + return parse_response(method, code, res.body) + end + end + + # A write that Apple accepted stays a success even when the body is not JSON + # (a proxy page, say), so nobody retries it. A GET must return JSON: a page + # without `data` would otherwise read as an empty list. + def parse_response(method, code, body) + JSON.parse(body) + rescue JSON::ParserError + raise Error, "HTTP #{code}: the response is not JSON" if method.to_s == 'get' + + { 'note' => "HTTP #{code}, the request succeeded but the response is not JSON", 'body' => body.to_s[0, 500] } + end + + def http_call(uri, method, body) + req = Net::HTTP.const_get(method.to_s.capitalize).new(uri) + req['Authorization'] = "Bearer #{token}" + req['Content-Type'] = 'application/json' + req.body = JSON.generate(body) if body + Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 90) { |h| h.request(req) } + rescue SocketError, Timeout::Error, SystemCallError, OpenSSL::SSL::SSLError => e + raise Error, "network error: #{e.class}: #{e.message}" + end + + # Follows links.next; returns {'data' => [...], 'included' => [...]}. + def paginate(target) + data = [] + included = [] + while target + page = request(:get, target) + data.concat(page['data'] || []) + included.concat(page['included'] || []) + target = page.dig('links', 'next') + end + { 'data' => data, 'included' => included } + end + + def index_included(included) + (included || []).each_with_object({}) { |r, h| h[[r['type'], r['id']]] = r } + end + + USAGE = <<~TXT + usage: ruby asc.rb --credentials + ruby asc.rb GET [--paginate] + ruby asc.rb POST|PATCH|DELETE [json | @file] --write + TXT + + # Returns the exit code: 0 ok, 1 HTTP error, 2 usage or configuration error. + def cli(argv) + flags, args = argv.partition { |a| a.start_with?('--') } + unknown = flags - %w[--credentials --paginate --write] + return usage("unknown flag #{unknown.first}") unless unknown.empty? + return print_credentials if flags.include?('--credentials') + + method, target, body = args + method = method&.upcase + return usage('need a method and a path') unless %w[GET POST PATCH DELETE].include?(method) && target + return usage('refusing to write without --write; add it only after the user approved the Price Proposal') if method != 'GET' && !flags.include?('--write') + + begin + payload = body && JSON.parse(body.start_with?('@') ? File.read(body[1..-1]) : body) + rescue JSON::ParserError, Errno::ENOENT => e + return usage("bad request body (#{e.class})") + end + result = if method == 'GET' && flags.include?('--paginate') + paginate(target) + else + request(method.downcase.to_sym, target, payload) + end + puts method == 'GET' ? 'HTTP 200' : 'HTTP 2xx' + puts JSON.pretty_generate(result) if result + 0 + rescue HttpError => e + puts "HTTP #{e.code}" + puts e.message + 1 + rescue Error => e + warn "ERROR: #{e.message}" + 2 + end + + def usage(message) + warn "ERROR: #{message}", USAGE + 2 + end + + def print_credentials + creds = credentials + private_key(creds.p8_path) + puts "key_id=#{creds.key_id}", "issuer_id=#{creds.issuer_id}", "p8_path=#{creds.p8_path}", "config_path=#{CONFIG_PATH}" + 0 + end +end + +exit(Asc.cli(ARGV)) if $PROGRAM_NAME == __FILE__ diff --git a/skills/territory-pricing/scripts/check_setup.sh b/skills/territory-pricing/scripts/check_setup.sh new file mode 100755 index 0000000..3bd3f3c --- /dev/null +++ b/skills/territory-pricing/scripts/check_setup.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# territory-pricing setup check. One PASS/WARN/FAIL line per prerequisite and +# a fix hint for anything missing. Never prints key material. +# Exit code 0 only when the App Store Connect key works end to end. +set -uo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" +HOME_DIR="$HOME/.appstoreconnect" +FAILS=0 + +pass() { echo " PASS $1"; } +warn() { echo " WARN $1"; } +fail() { echo " FAIL $1"; FAILS=$((FAILS+1)); } + +perms() { stat -f "%Lp" "$1" 2>/dev/null || stat -c "%a" "$1" 2>/dev/null; } + +check_perms() { # $1 path, $2 expected mode, $3 label + local p; p=$(perms "$1") + if [ "$p" = "$2" ]; then pass "$3 permissions $2"; else warn "$3 permissions are $p, run: chmod $2 $1"; fi +} + +echo "territory-pricing setup check" +echo + +if command -v ruby >/dev/null; then + pass "ruby $(ruby -e 'print RUBY_VERSION')" +else + fail "ruby missing (ships with macOS; needed for the App Store Connect client)" + echo; echo "1 check(s) failed - fix the FAIL lines above before pricing anything"; exit 1 +fi + +# key, key id, issuer id: resolved by the same code the pricing scripts use +out=$(ruby "$DIR/asc.rb" --credentials 2>&1); rc=$? +if [ $rc -ne 0 ]; then + fail "${out#ERROR: }" + cat < Users and Access -> Integrations -> App Store Connect API + -> Team Keys -> Generate API Key, access: App Manager (or Admin). + 2. Download AuthKey_.p8 (downloadable once) and save it as + $HOME_DIR/private_keys/AuthKey_.p8 + 3. Save the Issuer ID (UUID at the top of the Team Keys page): + echo '{"issuer_id": ""}' > $HOME_DIR/config.json + 4. chmod 700 $HOME_DIR $HOME_DIR/private_keys && chmod 600 $HOME_DIR/config.json $HOME_DIR/private_keys/*.p8 +HINT + echo; echo "$FAILS check(s) failed - fix the FAIL lines above before pricing anything"; exit 1 +fi + +KEY_ID=$(echo "$out" | sed -n 's/^key_id=//p') +ISSUER=$(echo "$out" | sed -n 's/^issuer_id=//p') +P8=$(echo "$out" | sed -n 's/^p8_path=//p') +CFG=$(echo "$out" | sed -n 's/^config_path=//p') + +pass "private key found and parses as EC P-256 (key id $KEY_ID)" +pass "issuer id set (${ISSUER:0:8}...)" + +# permissions +[ -d "$HOME_DIR" ] && check_perms "$HOME_DIR" 700 "key folder" +check_perms "$P8" 600 "private key" +[ -f "$CFG" ] && check_perms "$CFG" 600 "config" +case "$P8" in + "$HOME"/*) ;; + *) warn "key is outside your home folder: $P8" ;; +esac +if git -C "$(dirname "$P8")" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + warn "key sits inside a git work tree - move it out so it cannot be committed" +fi + +# live round trip +res=$(ruby "$DIR/asc.rb" GET '/v1/apps?limit=1' 2>&1); code=$(echo "$res" | head -1) +case "$code" in + "HTTP 200") pass "App Store Connect API round trip (HTTP 200)" ;; + "HTTP 401") fail "API rejected the key (HTTP 401): key id, issuer id and .p8 do not belong together" ;; + "HTTP 403") fail "API forbids the request (HTTP 403): the key role is too low, needs App Manager or Admin" ;; + *) fail "API round trip failed: $(echo "$res" | head -2 | tr '\n' ' ')" ;; +esac + +echo +if [ "$FAILS" -eq 0 ]; then + echo "setup OK" +else + echo "$FAILS check(s) failed - fix the FAIL lines above before pricing anything" + exit 1 +fi diff --git a/skills/territory-pricing/scripts/collect_points.rb b/skills/territory-pricing/scripts/collect_points.rb new file mode 100755 index 0000000..929bd97 --- /dev/null +++ b/skills/territory-pricing/scripts/collect_points.rb @@ -0,0 +1,61 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Reads the price points of every territory in short, resumable batches. +# A subscription has about 800 price points per territory and Apple returns +# 200 per page, so a full sweep is ~700 requests: too long for one shell +# command (10 minute limit). Each run handles one batch and saves every +# territory to its own file; run it again until it prints "done". +# +# ruby collect_points.rb RUN_DIR POINTS_PATH +# +# POINTS_PATH is the product's price-points endpoint, for example +# /v1/subscriptions//pricePoints +# /v1/apps//appPricePoints +# /v2/inAppPurchases//pricePoints +# RUN_DIR/territories.json lists the territory codes to read: ["USA", "BRA", ...]. +# Output: RUN_DIR/points/.json = [{"id", "price"}, ...] +require 'json' +require 'fileutils' +require_relative 'asc' + +batch = Integer(ENV.fetch('COLLECT_BATCH', 40)) # territories per run, about 30 s +threads = Integer(ENV.fetch('COLLECT_THREADS', 6)) # more threads mostly buys HTTP 429 answers + +run, path = ARGV +abort 'usage: ruby collect_points.rb RUN_DIR POINTS_PATH' unless run && path + +dir = File.join(run, 'points') +FileUtils.mkdir_p(dir) +territories = JSON.parse(File.read(File.join(run, 'territories.json'))) +saved = ->(t) { File.exist?(File.join(dir, "#{t}.json")) } +queue = Queue.new +territories.reject(&saved).first(batch).each { |t| queue << t } +failed = Queue.new + +Array.new(threads) do + Thread.new do + loop do + t = begin + queue.pop(true) + rescue ThreadError + break + end + begin + page = Asc.paginate(Asc.path(path, 'filter[territory]' => t, 'limit' => 200)) + points = page['data'].map { |d| { 'id' => d['id'], 'price' => d.dig('attributes', 'customerPrice').to_f } } + File.write(File.join(dir, "#{t}.tmp"), JSON.generate(points)) + File.rename(File.join(dir, "#{t}.tmp"), File.join(dir, "#{t}.json")) + rescue StandardError => e + failed << "#{t}: #{e.is_a?(Asc::Error) ? e.message : "#{e.class}: #{e.message}"}" + end + end + end +end.each(&:join) + +left = territories.reject(&saved).size +failures = Array.new(failed.size) { failed.pop } +puts "#{territories.size - left}/#{territories.size} territories saved" +puts "failed: #{failures.join('; ')}" unless failures.empty? +puts(left.zero? ? 'done' : "run again: #{left} left") +exit(failures.empty? ? 0 : 1) diff --git a/skills/territory-pricing/scripts/ladder.rb b/skills/territory-pricing/scripts/ladder.rb new file mode 100755 index 0000000..a3141f7 --- /dev/null +++ b/skills/territory-pricing/scripts/ladder.rb @@ -0,0 +1,61 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Checks the price ladder across the Price Proposals of one app: per territory a +# longer subscription is pricier but cheaper per month, and a lifetime unlock +# costs at least N times the cheapest yearly cost. Local files only. +# +# ruby ladder.rb RUN_A/proposal.json RUN_B/proposal.json ... [--lifetime-min-years 2] +# +# Each proposal needs meta.product.period: the subscription period (ONE_MONTH, +# ONE_YEAR, ...) or LIFETIME for a lifetime unlock. +# Exit: 0 clean, 1 violations (also written to ladder.json next to the first +# proposal), 2 a proposal cannot be read or has no period. +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'optparse' +require 'json' +require 'ladder' + +min_years = 2.0 + +def fail_with(message) + warn "ERROR: #{message}" + exit 2 +end + +OptionParser.new do |o| + o.on('--lifetime-min-years F', Float, 'lifetime must cost at least F x the yearly price, default 2') { |v| min_years = v } +end.parse! +fail_with 'usage: ruby ladder.rb PROPOSAL.json PROPOSAL.json ... [--lifetime-min-years F]' if ARGV.size < 2 + +products = ARGV.map do |path| + proposal = JSON.parse(File.read(path)) + product = proposal.dig('meta', 'product') || {} + period = product['period'] + fail_with "#{path} has no meta.product.period; put \"period\" in the product of plan-input.json" unless period + + rows = proposal['rows'].select { |r| r['proposed'] } + { 'name' => product['name'] || File.basename(File.dirname(path)), 'period' => period, + 'prices' => rows.map { |r| [r['territory'], r['proposed']] }.to_h, + 'currencies' => rows.map { |r| [r['territory'], r['currency']] }.to_h } +rescue Errno::ENOENT, JSON::ParserError => e + fail_with "cannot read #{path} (#{e.class})" +end + +begin + result = Ladder.check(products, min_lifetime_years: min_years) +rescue ArgumentError => e + fail_with e.message +end +File.write(File.join(File.dirname(ARGV.first), 'ladder.json'), + JSON.pretty_generate(result.merge('min_lifetime_years' => min_years, 'products' => products.map { |p| p.values_at('name', 'period') }))) + +puts "Ladder: #{products.map { |p| "#{p['name']} (#{p['period']})" }.join(', ')}" +puts "Checked #{result['checked']} territories, skipped #{result['skipped']} priced in only one product; lifetime must cost at least #{min_years}x yearly" +if result['violations'].empty? + puts 'No violations.' + exit 0 +end +puts "#{result['violations'].size} violation(s):" +result['violations'].each { |v| puts format(' %-4s %-4s %-36s %s', v['territory'], v['currency'], v['rule'], v['detail']) } +exit 1 diff --git a/skills/territory-pricing/scripts/lib/ladder.rb b/skills/territory-pricing/scripts/lib/ladder.rb new file mode 100644 index 0000000..427caac --- /dev/null +++ b/skills/territory-pricing/scripts/lib/ladder.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# Price ladder across the products of one app, per territory: a longer +# subscription costs more in total but less per month, and a lifetime unlock +# costs at least `min_lifetime_years` times the cheapest yearly cost of the +# subscriptions. Pure logic: no network, no file IO. +module Ladder + MONTHS = { + 'ONE_WEEK' => 12.0 / 52, 'ONE_MONTH' => 1.0, 'TWO_MONTHS' => 2.0, + 'THREE_MONTHS' => 3.0, 'SIX_MONTHS' => 6.0, 'ONE_YEAR' => 12.0 + }.freeze + LIFETIME = 'LIFETIME' + EPSILON = 1e-9 + + module_function + + # products: [{'name', 'period', 'prices' => {'BRA' => 34.9}, 'currencies' => {'BRA' => 'BRL'}}] + # -> {'violations' => [{'territory','currency','rule','detail'}], 'checked' => n, 'skipped' => n} + # A territory priced in fewer than two products cannot be compared and is skipped. + def check(products, min_lifetime_years: 2.0) + unknown = products.reject { |p| MONTHS.key?(p['period']) || p['period'] == LIFETIME } + raise ArgumentError, "unknown period #{unknown.first['period'].inspect} of #{unknown.first['name']}" unless unknown.empty? + + subs = products.select { |p| MONTHS.key?(p['period']) }.sort_by { |p| [MONTHS[p['period']], p['name'].to_s] } + lifetimes = products.select { |p| p['period'] == LIFETIME } + result = { 'violations' => [], 'checked' => 0, 'skipped' => 0 } + products.flat_map { |p| p['prices'].keys }.uniq.sort.each do |territory| + if products.count { |p| p['prices'][territory] } < 2 + result['skipped'] += 1 + next + end + result['checked'] += 1 + currency = products.map { |p| (p['currencies'] || {})[territory] }.compact.first + found = compare_subscriptions(subs, territory) + compare_lifetime(subs, lifetimes, territory, min_lifetime_years) + found.each { |rule, detail| result['violations'] << { 'territory' => territory, 'currency' => currency, 'rule' => rule, 'detail' => detail } } + end + result + end + + # Every pair of subscriptions with different periods, not only neighbours: + # with two monthly plans both must be compared with the yearly one. + def compare_subscriptions(subs, territory) + subs.select { |p| p['prices'][territory] }.combination(2).flat_map do |short, long| + next [] if MONTHS[short['period']] == MONTHS[long['period']] + + s = short['prices'][territory] + l = long['prices'][territory] + found = [] + found << ['longer_period_not_pricier', "#{describe(long, l)} is not above #{describe(short, s)}"] if l <= s + EPSILON + if l / MONTHS[long['period']] > s / MONTHS[short['period']] * (1 + EPSILON) + found << ['longer_period_costs_more_per_month', "#{describe(long, l)} costs more per month than #{describe(short, s)}"] + end + found + end + end + + def compare_lifetime(subs, lifetimes, territory, min_years) + priced = subs.select { |p| p['prices'][territory] } + return [] if priced.empty? + + cheapest = priced.map { |p| [p['prices'][territory] * 12 / MONTHS[p['period']], p] }.min_by(&:first) + lifetimes.select { |p| p['prices'][territory] }.flat_map do |life| + price = life['prices'][territory] + next [] unless price < cheapest[0] * min_years * (1 - EPSILON) + + [['lifetime_too_cheap', "#{describe(life, price)} is below #{min_years}x the yearly cost of #{cheapest[1]['name']} (#{format('%.2f', cheapest[0])})"]] + end + end + + def describe(product, price) + "#{product['name']} #{format('%.2f', price)}" + end +end diff --git a/skills/territory-pricing/scripts/lib/pricing.rb b/skills/territory-pricing/scripts/lib/pricing.rb new file mode 100644 index 0000000..3663740 --- /dev/null +++ b/skills/territory-pricing/scripts/lib/pricing.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +require 'csv' + +# Pure pricing logic: no network, no file IO. Rows and entries use string keys +# so they round-trip through JSON untouched; options use symbol keys. +module Pricing + STRATEGIES = %w[equalization gni_bands big_mac ppp tiered].freeze + + DEFAULT_OPTIONS = { + base_territory: 'USA', + clamp_min: 0.30, + clamp_max: 1.30, + flag_threshold: 0.25, + band1_min_gni: 14_000.0, + band2_min_gni: 4_500.0, + band_ratios: { 1 => 1.0, 2 => 0.6, 3 => 0.3 }, + tier_ratios: { 1 => 1.0, 2 => 0.7, 3 => 0.4 }, + default_tier: 1, + endings: 'none', + endings_tolerance: 0.05, + endings_rules: {} + }.freeze + + # Options too large or too internal to store in proposal.json meta. + TRANSIENT_OPTIONS = %i[band_ratios tier_ratios endings_rules].freeze + + ENDING_MODES = %w[none charm].freeze + # Cent-based currencies without a rule of their own like .99 and .49. + DEFAULT_CENT_ENDINGS = [99, 49].freeze + + CSV_COLUMNS = %w[ + territory currency current equalized strategy ratio_source ratio clamped + target proposed change_pct flag note price_point_id current_point_id + ].freeze + + # fallback chain per strategy; everything ends in plain equalization (1.0) + CHAINS = { + 'big_mac' => %w[big_mac ppp], + 'ppp' => %w[ppp], + 'gni_bands' => %w[gni_bands], + 'tiered' => %w[tiered] + }.freeze + + module_function + + def options(overrides = {}) + DEFAULT_OPTIONS.merge(overrides) + end + + def persisted_options(opts) + opts.reject { |k, _| TRANSIENT_OPTIONS.include?(k) } + end + + # -> [ratio, source]; ratio multiplies the equalized local price. + def ratio_for(strategy, territory, data, opts) + CHAINS.fetch(strategy, []).each do |name| + ratio = send("#{name}_ratio", territory, data, opts) + return [ratio, name] if ratio + end + [1.0, 'equalization'] + end + + # Dollar price of a Big Mac relative to the base territory's. + def big_mac_ratio(territory, data, opts) + relative(data[:big_mac], territory, opts) + end + + # PPP price level (PPP factor / market exchange rate), relative to the base. + def ppp_ratio(territory, data, opts) + relative(data[:ppp], territory, opts) + end + + def relative(values, territory, opts) + values ||= {} + base = values[opts[:base_territory]] + value = values[territory] + return nil unless base && value && base.positive? && value.positive? + + value / base + end + + def gni_bands_ratio(territory, data, opts) + return opts[:band_ratios][1] if (data[:band1_overrides] || []).include?(territory) + + gni = (data[:gni] || {})[territory] + return nil unless gni + + band = if gni >= opts[:band1_min_gni] then 1 + elsif gni >= opts[:band2_min_gni] then 2 + else 3 + end + opts[:band_ratios][band] + end + + def tiered_ratio(territory, data, opts) + tier = (data[:tiers] || {}).fetch(territory, opts[:default_tier]) + opts[:tier_ratios][tier] + end + + def clamp_ratio(ratio, opts) + [[ratio, opts[:clamp_min]].max, opts[:clamp_max]].min + end + + # Nearest price point; on a tie the cheaper one wins. + # Nearest paid price point (a 0.00 point would make the product free); on a + # tie the cheaper one wins. + def nearest_point(target, points) + paid(points).min_by { |p| [(p['price'] - target).abs, p['price']] } + end + + def paid(points) + points.select { |p| p['price'].to_f.positive? } + end + + # Prices in effect can sit on legacy price points that Apple no longer + # lists (and whose equalizations 404). Map such a point onto the listed + # point with the same price; otherwise keep the id as is. + def resolve_point_id(point_id, price, points) + return point_id if point_id.nil? || points.any? { |p| p['id'] == point_id } || price.nil? + + match = points.min_by { |p| (p['price'] - price).abs } + match && (match['price'] - price).abs < 0.005 ? match['id'] : point_id + end + + # input: {'territory','currency','current','current_point_id','equalized','available','points'} + def build_row(input, strategy, data, opts) + row = { + 'territory' => input['territory'], 'currency' => input['currency'], + 'current' => input['current'], 'current_point_id' => input['current_point_id'], + 'equalized' => input['equalized'], 'strategy' => strategy, + 'ratio_source' => nil, 'ratio' => nil, 'clamped' => false, 'target' => nil, + 'price_point_id' => nil, 'proposed' => nil, 'change_pct' => nil, 'flag' => '', 'note' => '' + } + return skip(row, 'unavailable') unless input['available'] + return skip(row, 'no equalized price') unless input['equalized'] + return skip(row, 'no price points') if (input['points'] || []).empty? + + points = paid(input['points']) + return skip(row, 'no paid price points') if points.empty? + return base_row(row, input['equalized'], points, opts) if input['territory'] == opts[:base_territory] + + ratio, source = ratio_for(strategy, input['territory'], data, opts) + applied = clamp_ratio(ratio, opts) + target = input['equalized'] * applied + point, note = pick_point(target, points, input['currency'], opts) + row.merge!('ratio_source' => source, 'ratio' => applied.round(4), 'clamped' => applied != ratio, + 'target' => target.round(2), 'price_point_id' => point['id'], 'proposed' => point['price'], 'note' => note) + finish(row, opts) + end + + # Every equalized price was derived from the base price, so the base keeps it: + # no strategy ratio, no clamp, no endings. + def base_row(row, equalized, points, opts) + point = nearest_point(equalized, points) + finish(row.merge('ratio_source' => 'base', 'ratio' => 1.0, 'clamped' => false, 'target' => equalized.round(2), + 'price_point_id' => point['id'], 'proposed' => point['price']), opts) + end + + # Nearest price point, or with endings 'charm' the nearest one whose ending + # is customary in the currency, looked for within the tolerance around the + # target. The clamp already bounded the ratio that produced the target, so + # the window may reach slightly past it: a 3.49 for a 3.60 target is fine. + # -> [point, note] + def pick_point(target, points, currency, opts) + nearest = nearest_point(target, points) + return [nearest, ''] unless opts[:endings] == 'charm' + + tol = opts[:endings_tolerance] + lo = target * (1 - tol) + hi = target * (1 + tol) + whole = points.none? { |p| (p['price'] % 1).abs > 1e-9 } + nice = points.select { |p| p['price'].between?(lo, hi) && charm?(p['price'], currency, whole, opts) } + return [nearest, "no charm ending within #{(tol * 100).round}%"] if nice.empty? + + [nearest_point(target, nice), ''] + end + + # Is the price customary for the currency? A currency rule is + # {'unit','mod','endings'}: (price * unit).round % mod must be one of the + # endings. Without a rule: cent currencies use .99 / .49, whole-number + # currencies use round numbers (at most two significant digits). + def charm?(price, currency, whole, opts) + rule = (opts[:endings_rules] || {})[currency] + return rule['endings'].include?((price * rule['unit']).round % rule['mod']) if rule + return round_number?(price) if whole + + DEFAULT_CENT_ENDINGS.include?((price * 100).round % 100) + end + + def round_number?(price) + return false unless price.positive? + + (price.round % (10**[Math.log10(price).floor - 1, 0].max)).zero? + end + + # A skipped row is not repriced, and it keeps its current price point: an + # app or in-app purchase schedule lists every manual price, so a row + # without one would drop the territory back to Apple's automatic price. + # ratio_source stays nil, which is what marks the row as skipped. + def skip(row, note) + row.merge('note' => note, 'price_point_id' => row['current_point_id'], 'proposed' => row['current']) + end + + def skipped?(row) + row['ratio_source'].nil? + end + + def finish(row, opts) + cur = row['current'] + row['change_pct'] = cur&.positive? ? ((row['proposed'] - cur) / cur).round(4) : nil + flags = [] + flags << 'clamped' if row['clamped'] + flags << 'large-change' if row['change_pct'] && row['change_pct'].abs > opts[:flag_threshold] + row['flag'] = flags.join(',') + row + end + + def build_proposal(inputs, strategy, data, opts) + inputs.map { |i| build_row(i, strategy, data, opts) } + end + + def changed?(row) + !row['price_point_id'].nil? && row['price_point_id'] != row['current_point_id'] + end + + def summary(rows) + priced = rows.reject { |r| skipped?(r) } + changed = rows.select { |r| changed?(r) } + { + 'territories' => rows.size, + 'priced' => priced.size, + 'changed' => changed.size, + 'decreases' => changed.count { |r| r['change_pct'] && r['change_pct'] < 0 }, + 'increases' => changed.count { |r| r['change_pct'] && r['change_pct'] > 0 }, + 'flagged' => rows.count { |r| !r['flag'].to_s.empty? }, + 'skipped' => rows.count { |r| skipped?(r) }, + 'sources' => priced.each_with_object(Hash.new(0)) { |r, h| h[r['ratio_source']] += 1 } + } + end + + def to_csv(rows) + CSV.generate do |csv| + csv << CSV_COLUMNS + rows.each { |r| csv << CSV_COLUMNS.map { |c| r[c] } } + end + end + + class EditError < StandardError + attr_reader :problems + + def initialize(problems) + @problems = problems + super("#{problems.size} cell(s) in proposal.csv cannot be read as a price") + end + end + + # '9,90', '$4.99', '1,299.00', '1.299,00', '1 299' -> Float; nil when unreadable + # or not positive. The last separator is the decimal one. A lone separator + # with exactly three digits after it ('1.099') is ambiguous: with `near` (the + # row's price) the reading closer to it wins, without it thousands (1099). + def parse_price(raw, near: nil) + s = raw.to_s.gsub(/[^\d.,]/, '') + return nil unless s =~ /\d/ + + last = [s.rindex(','), s.rindex('.')].compact.max + readings = if last.nil? + [s] + elsif s[(last + 1)..-1].length == 3 && s.count(',.') == 1 + [s.delete(',.'), s.tr(',', '.')] + else + ["#{s[0...last].delete(',.')}.#{s[(last + 1)..-1]}"] + end + values = readings.map { |v| Float(v, exception: false) }.compact.select(&:positive?) + return values.first unless near&.positive? && values.size > 1 + + values.min_by { |v| Math.log(v / near).abs } + end + + # Re-snaps hand-edited `proposed` values of a proposal.csv onto real price + # points. An emptied `proposed` cell means "leave this territory alone". + # Unreadable cells raise EditError before anything is changed. + # points_by_territory: {'BRA' => [{'id','price'}, ...]} + def apply_csv_edits(rows, csv_text, points_by_territory, opts) + by_territory = rows.map { |r| [r['territory'], r] }.to_h + cells = CSV.parse(csv_text, headers: true).each_with_object({}) { |r, h| h[r['territory']] = r['proposed'].to_s.strip } + touched = cells.reject { |t, raw| untouched?(by_territory[t], raw) } + problems = touched.reject { |t, raw| raw.empty? || parse_price(raw, near: near_price(by_territory[t])) } + .map { |t, raw| "#{t}: '#{raw}'" } + raise EditError, problems unless problems.empty? + + rows.map do |row| + raw = touched[row['territory']] + next row if raw.nil? + next keep_current(row, opts) if raw.empty? + + value = parse_price(raw, near: near_price(row)) + point = nearest_point(value, points_by_territory.fetch(row['territory'], [])) + next row.merge('note' => 'no price points for manual edit') unless point + + finish(row.merge('price_point_id' => point['id'], 'proposed' => point['price'], 'ratio_source' => 'manual', + 'clamped' => false, 'ratio' => nil, 'target' => value.round(2), 'note' => 'edited by user'), opts) + end + end + + # A cell is untouched when it still holds what to_csv wrote, or the same + # number in another notation. Untouched rows, skipped ones included, are kept. + def untouched?(row, raw) + return true if row.nil? + + written = row['proposed'].to_s + return true if raw == written + return false if raw.empty? || written.empty? + + value = parse_price(raw, near: row['proposed']) + !value.nil? && (value - row['proposed']).abs < 0.005 + end + + def near_price(row) + row && (row['proposed'] || row['current']) + end + + def keep_current(row, opts) + return row.merge('note' => 'no current price to keep') if row['current'].nil? || row['current_point_id'].nil? + + finish(row.merge('price_point_id' => row['current_point_id'], 'proposed' => row['current'], 'ratio_source' => 'unchanged', + 'clamped' => false, 'ratio' => nil, 'target' => nil, 'note' => 'left unchanged by user'), opts) + end +end diff --git a/skills/territory-pricing/scripts/lib/schedule.rb b/skills/territory-pricing/scripts/lib/schedule.rb new file mode 100644 index 0000000..d0f9a70 --- /dev/null +++ b/skills/territory-pricing/scripts/lib/schedule.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'date' +require 'pricing' + +# Builds the body of POST /v1/appPriceSchedules and +# POST /v1/inAppPurchasePriceSchedules from a Price Proposal. Pure logic. +# +# Without a start date every territory gets one manual price that takes effect +# at once. With a start date a territory whose price changes keeps its current +# point until that day and switches on it; unchanged territories keep one +# immediate price. Shapes follow Apple's documented examples; the dated form is +# not verified against the live API. +module Schedule + KINDS = { + 'app' => { type: 'appPriceSchedules', rel: 'app', rel_type: 'apps', price_type: 'appPrices', + point_rel: 'appPricePoint', point_type: 'appPricePoints' }, + 'iap' => { type: 'inAppPurchasePriceSchedules', rel: 'inAppPurchase', rel_type: 'inAppPurchases', + price_type: 'inAppPurchasePrices', point_rel: 'inAppPurchasePricePoint', + point_type: 'inAppPurchasePricePoints' } + }.freeze + + module_function + + # rows: proposal rows; those without a price_point_id are left out of the schedule. + def body(kind:, product_id:, base:, rows:, start_date: nil, today: Date.today) + cfg = KINDS.fetch(kind) { raise ArgumentError, "unknown kind #{kind.inspect}" } + validate_start_date!(start_date, today) + prices = rows.select { |r| r['price_point_id'] }.flat_map { |row| entries(kind, row, start_date) } + included = prices.each_with_index.map { |e, i| resource(kind, cfg, product_id, "${price#{i}}", e) } + { 'data' => { 'type' => cfg[:type], 'attributes' => {}, + 'relationships' => { cfg[:rel] => reference(cfg[:rel_type], product_id), + 'baseTerritory' => reference('territories', base), + 'manualPrices' => { 'data' => included.map { |i| { 'type' => cfg[:price_type], 'id' => i['id'] } } } } }, + 'included' => included } + end + + def validate_start_date!(start_date, today) + return unless start_date + + ok = Date.strptime(start_date, '%Y-%m-%d') > today + raise ArgumentError, "start date must be after #{today}" unless ok + rescue ArgumentError => e + raise ArgumentError, e.message.start_with?('start date') ? e.message : "start date #{start_date.inspect} is not YYYY-MM-DD" + end + + # [{point, start, end}] for one proposal row + def entries(kind, row, start_date) + new_point = row['price_point_id'] + return [{ point: new_point, start: nil, end: nil }] unless start_date && Pricing.changed?(row) + return [{ point: new_point, start: start_date, end: nil }] unless row['current_point_id'] + + [{ point: row['current_point_id'], start: nil, end: kind == 'app' ? start_date : nil }, + { point: new_point, start: start_date, end: nil }] + end + + def resource(kind, cfg, product_id, id, entry) + attributes = { 'startDate' => entry[:start] } + attributes['endDate'] = entry[:end] if kind == 'app' + relationships = { cfg[:point_rel] => reference(cfg[:point_type], entry[:point]) } + relationships['inAppPurchaseV2'] = reference('inAppPurchases', product_id) if kind == 'iap' + { 'type' => cfg[:price_type], 'id' => id, 'attributes' => attributes, 'relationships' => relationships } + end + + def reference(type, id) + { 'data' => { 'type' => type, 'id' => id } } + end +end diff --git a/skills/territory-pricing/scripts/lib/snapshots.rb b/skills/territory-pricing/scripts/lib/snapshots.rb new file mode 100644 index 0000000..be65de9 --- /dev/null +++ b/skills/territory-pricing/scripts/lib/snapshots.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Where the bundled economic data lives; shared by plan.rb (reads) and +# refresh_data.rb (writes). +module Snapshots + DATA_DIR = File.expand_path('../../references/data', __dir__) + FILES = { big_mac: 'big-mac.csv', ppp: 'ppp.csv', gni: 'gni.csv' }.freeze +end diff --git a/skills/territory-pricing/scripts/plan.rb b/skills/territory-pricing/scripts/plan.rb new file mode 100755 index 0000000..ea7fe6b --- /dev/null +++ b/skills/territory-pricing/scripts/plan.rb @@ -0,0 +1,131 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Builds a Price Proposal from data the agent collected from App Store +# Connect. Reads and writes local files only; it never calls the API. +# +# ruby plan.rb --input plan-input.json --out DIR --strategy gni_bands [--base USA] +# ruby plan.rb --out DIR --rebuild # re-read an edited DIR/proposal.csv +# +# Input: {"product": {"kind", "id", "name", "period"}, "base_territory": "USA", "territories": [ +# {"territory", "currency", "current", "current_point_id", "equalized", +# "available" (default true), "points": [{"id", "price"}]}]} +# product.period (ONE_MONTH, ONE_YEAR, ..., LIFETIME) is only needed by ladder.rb. +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'optparse' +require 'fileutils' +require 'json' +require 'csv' +require 'time' +require 'pricing' +require 'snapshots' + +NEEDED = { 'big_mac' => %i[big_mac ppp], 'ppp' => %i[ppp], 'gni_bands' => %i[gni] }.freeze + +opts = { strategy: 'gni_bands' } +overrides = {} +OptionParser.new do |o| + o.on('--input FILE') { |v| opts[:input] = v } + o.on('--out DIR') { |v| opts[:out] = v } + o.on('--strategy S', Pricing::STRATEGIES) { |v| opts[:strategy] = v } + o.on('--base TERRITORY') { |v| overrides[:base_territory] = v.upcase } + o.on('--tiers FILE', 'JSON for --strategy tiered') { |v| opts[:tiers] = v } + o.on('--clamp-min F', Float) { |v| overrides[:clamp_min] = v } + o.on('--clamp-max F', Float) { |v| overrides[:clamp_max] = v } + o.on('--flag-threshold F', Float) { |v| overrides[:flag_threshold] = v } + o.on('--endings MODE', Pricing::ENDING_MODES, 'none (nearest price point) or charm (customary endings)') { |v| overrides[:endings] = v } + o.on('--endings-tolerance F', Float, 'how far from the target a charm price may be, default 0.05') { |v| overrides[:endings_tolerance] = v } + o.on('--endings-file FILE', 'ending rules per currency, default references/data/endings.json') { |v| opts[:endings_file] = v } + o.on('--rebuild') { opts[:rebuild] = true } +end.parse! +abort 'usage: see header of plan.rb' unless opts[:out] && (opts[:input] || opts[:rebuild]) + +def load_snapshot(name) + rows = CSV.read(File.join(Snapshots::DATA_DIR, Snapshots::FILES.fetch(name)), headers: true) + [rows.map { |r| [r['iso_a3'], Float(r['value'])] }.to_h, { 'origin' => 'snapshot', 'as_of' => rows.map { |r| r['as_of'] }.max }] +end + +def money(value, currency) + value ? format('%.2f %s', value, currency) : 'n/a' +end + +def print_summary(meta, rows) + s = Pricing.summary(rows) + puts "Price Proposal: #{meta['product']&.values_at('kind', 'id')&.join(' ')} | strategy #{meta['strategy']} | base #{meta['base_territory']}" + (meta['data'] || {}).each { |k, v| puts " data #{k}: #{v['origin']} (as of #{v['as_of']})" } + puts "Territories #{s['territories']} | priced #{s['priced']} | changed #{s['changed']} | decreases #{s['decreases']} | increases #{s['increases']} | flagged #{s['flagged']} | skipped #{s['skipped']}" + puts "Ratio sources: #{s['sources'].map { |k, v| "#{k} #{v}" }.join(', ')}" + o = (meta['options'] || {}).map { |k, v| [k.to_s, v] }.to_h + puts "Price endings: #{o['endings'] || 'none'}#{" (within #{(o['endings_tolerance'] * 100).round}% of the target)" if o['endings'] == 'charm'}" + moved = rows.select { |r| r['change_pct'] && Pricing.changed?(r) }.sort_by { |r| r['change_pct'] } + { 'Largest decreases' => moved.first(8).select { |r| r['change_pct'].negative? }, + 'Largest increases' => moved.last(8).reverse.select { |r| r['change_pct'].positive? } }.each do |title, list| + next if list.empty? + + puts "#{title}:" + list.each { |r| puts format(' %-4s %-14s -> %-14s %+6.1f%% %s', r['territory'], money(r['current'], r['currency']), money(r['proposed'], r['currency']), r['change_pct'] * 100, r['flag']) } + end + skipped = rows.select { |r| Pricing.skipped?(r) } + puts "Skipped, current price kept: #{skipped.map { |r| "#{r['territory']} (#{r['note']})" }.join(', ')}" unless skipped.empty? +end + +FileUtils.mkdir_p(opts[:out]) +proposal_path = File.join(opts[:out], 'proposal.json') +csv_path = File.join(opts[:out], 'proposal.csv') + +if opts[:rebuild] + proposal = JSON.parse(File.read(proposal_path)) + # same settings as the original run, unless the command line says otherwise + stored = (proposal['meta']['options'] || {}).map { |k, v| [k.to_sym, v] }.to_h + popts = Pricing.options(stored.merge(overrides).merge(base_territory: proposal['meta']['base_territory'])) + begin + proposal['rows'] = Pricing.apply_csv_edits(proposal['rows'], File.read(csv_path), proposal['points'], popts) + rescue Pricing::EditError => e + warn "ERROR: #{e.message}; nothing was changed. Fix these cells and run --rebuild again:" + e.problems.each { |line| warn " #{line}" } + exit 2 + end + proposal['meta']['options'] = Pricing.persisted_options(popts) + File.write(proposal_path, JSON.pretty_generate(proposal)) + File.write(csv_path, Pricing.to_csv(proposal['rows'])) + print_summary(proposal['meta'], proposal['rows']) + exit 0 +end + +input = JSON.parse(File.read(opts[:input])) +base = overrides[:base_territory] || input['base_territory'] || 'USA' +endings_file = opts[:endings_file] || File.join(Snapshots::DATA_DIR, 'endings.json') +overrides[:endings_rules] = JSON.parse(File.read(endings_file))['currencies'] +popts = Pricing.options(overrides.merge(base_territory: base)) +inputs = input['territories'].map do |t| + t.merge('available' => t.fetch('available', true), + # a price in effect may sit on a legacy point Apple no longer lists + 'current_point_id' => Pricing.resolve_point_id(t['current_point_id'], t['current'], t['points'] || [])) +end +abort "ERROR: base territory #{base} is missing from the input" unless inputs.any? { |t| t['territory'] == base } + +data = { band1_overrides: JSON.parse(File.read(File.join(Snapshots::DATA_DIR, 'gni-bands.json')))['band1_overrides'] } +provenance = {} +(NEEDED[opts[:strategy]] || []).each { |name| data[name], provenance[name.to_s] = load_snapshot(name) } +if opts[:strategy] == 'tiered' + abort 'ERROR: --strategy tiered needs --tiers FILE' unless opts[:tiers] + + cfg = JSON.parse(File.read(opts[:tiers])) + data[:tiers] = (cfg['territories'] || {}).map { |t, tier| [t, Integer(tier)] }.to_h + # tiers missing from the file keep their default ratio + tier_ratios = Pricing::DEFAULT_OPTIONS[:tier_ratios].merge((cfg['tier_ratios'] || {}).map { |k, v| [Integer(k), Float(v)] }.to_h) + popts = popts.merge(tier_ratios: tier_ratios) + popts = popts.merge(default_tier: Integer(cfg['default_tier'])) if cfg['default_tier'] + unknown = (data[:tiers].values + [popts[:default_tier]]).uniq - tier_ratios.keys + abort "ERROR: no ratio for tier #{unknown.join(', ')}; add it to tier_ratios in #{opts[:tiers]}" unless unknown.empty? +end + +rows = Pricing.build_proposal(inputs, opts[:strategy], data, popts) +meta = { 'product' => input['product'], 'strategy' => opts[:strategy], 'base_territory' => base, + 'created_at' => Time.now.utc.iso8601, 'data' => provenance, + 'options' => Pricing.persisted_options(popts) } +points = inputs.map { |t| [t['territory'], t['points'] || []] }.to_h +File.write(proposal_path, JSON.pretty_generate('meta' => meta, 'rows' => rows, 'points' => points)) +File.write(csv_path, Pricing.to_csv(rows)) +print_summary(meta, rows) +puts "Files: #{proposal_path}, #{csv_path}" diff --git a/skills/territory-pricing/scripts/refresh_data.rb b/skills/territory-pricing/scripts/refresh_data.rb new file mode 100755 index 0000000..f2480c1 --- /dev/null +++ b/skills/territory-pricing/scripts/refresh_data.rb @@ -0,0 +1,134 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Refreshes the bundled snapshots in references/data/ from the live sources +# (The Economist Big Mac index, World Bank indicators). plan.rb reads only the +# snapshots, so run this when the data should be newer. +# ruby refresh_data.rb + +require 'csv' +require 'json' +require 'net/http' +require 'uri' +require 'time' +require 'set' +require_relative 'lib/snapshots' + +module DataSources + BIG_MAC_URL = 'https://raw.githubusercontent.com/TheEconomist/big-mac-data/master/output-data/big-mac-full-index.csv' + WORLD_BANK = 'https://api.worldbank.org/v2' + BIG_MAC_MAX_AGE_YEARS = 3 + WB_YEARS = '2019:2026' + EUROZONE = %w[AUT BEL BGR HRV CYP EST FIN FRA DEU GRC IRL ITA LVA LTU LUX MLT NLD PRT SVK SVN ESP].freeze + + module_function + + def get(url, redirects = 3) + uri = URI(url) + res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 40) { |h| h.get(uri.request_uri) } + return get(res['location'], redirects - 1) if res.is_a?(Net::HTTPRedirection) && redirects.positive? + raise "HTTP #{res.code} from #{uri.host}" unless res.is_a?(Net::HTTPSuccess) + + res.body + end + + # ---- Big Mac index (The Economist) ------------------------------------- + + def fetch_big_mac + values, as_of = parse_big_mac(get(BIG_MAC_URL), Time.now.utc.year) + raise 'empty Big Mac data' if values.empty? + + [values, { 'origin' => 'live', 'as_of' => as_of, 'source' => BIG_MAC_URL }] + end + + # -> [{'BRA' => dollar_price}, as_of]; keeps each country's latest row, + # drops stale ones, spreads the euro-area row over its members. + def parse_big_mac(csv_text, this_year) + latest = {} + CSV.parse(csv_text, headers: true).each do |r| + price = Float(r['dollar_price'], exception: false) + next unless price && r['iso_a3'] && (latest[r['iso_a3']].nil? || r['date'] > latest[r['iso_a3']][:date]) + + latest[r['iso_a3']] = { date: r['date'], price: price } + end + fresh = latest.select { |_, v| v[:date][0, 4].to_i >= this_year - BIG_MAC_MAX_AGE_YEARS } + values = {} + fresh.each { |iso, v| values[iso] = v[:price] } + (fresh['EUZ'] ? EUROZONE : []).each { |iso| values[iso] ||= fresh['EUZ'][:price] } + values.delete('EUZ') + [values, fresh.values.map { |v| v[:date] }.max] + end + + # ---- World Bank --------------------------------------------------------- + + def fetch_ppp + levels = {} + years = {} + ppp = world_bank('PA.NUS.PRVT.PP') + fx = world_bank('PA.NUS.FCRF') + ppp.each do |iso, by_year| + year = (by_year.keys & (fx[iso] || {}).keys).max + next unless year && fx[iso][year].positive? + + levels[iso] = by_year[year] / fx[iso][year] + years[iso] = year + end + raise 'empty PPP data' if levels.empty? + + [levels, { 'origin' => 'live', 'as_of' => years.values.max, 'source' => "#{WORLD_BANK} PA.NUS.PRVT.PP / PA.NUS.FCRF" }] + end + + def fetch_gni + values = {} + years = {} + world_bank('NY.GNP.PCAP.CD').each do |iso, by_year| + year = by_year.keys.max + values[iso] = by_year[year] + years[iso] = year + end + raise 'empty GNI data' if values.empty? + + [values, { 'origin' => 'live', 'as_of' => years.values.max, 'source' => "#{WORLD_BANK} NY.GNP.PCAP.CD" }] + end + + # -> {'BRA' => {2024 => value, ...}}, countries only (no aggregates) + def world_bank(indicator) + countries = country_codes + rows = JSON.parse(get("#{WORLD_BANK}/country/all/indicator/#{indicator}?format=json&per_page=20000&date=#{WB_YEARS}"))[1] || [] + rows.each_with_object({}) do |r, h| + next unless r['value'] && countries.include?(r['countryiso3code']) + + (h[r['countryiso3code']] ||= {})[r['date'].to_i] = r['value'].to_f + end + end + + # The country list is the same for every indicator: fetch it once. + def country_codes + @country_codes ||= JSON.parse(get("#{WORLD_BANK}/country?format=json&per_page=400"))[1] + .reject { |c| c['region']['id'] == 'NA' }.map { |c| c['id'] }.to_set + end + + # Fetches and writes every source on its own, so one broken source does not + # stop the others. -> names of the sources that failed + def refresh_all(out = $stdout) + %i[big_mac ppp gni].each_with_object([]) do |name, failed| + begin + values, meta = send("fetch_#{name}") + write_snapshot(name, values, meta['as_of']) + out.puts "#{name}: #{values.size} countries, as of #{meta['as_of']}" + rescue StandardError => e + failed << name + out.puts "#{name}: FAILED (#{e.class}: #{e.message}); snapshot left unchanged" + end + end + end + + def write_snapshot(name, values, as_of) + File.write(File.join(Snapshots::DATA_DIR, Snapshots::FILES.fetch(name)), CSV.generate do |csv| + csv << %w[iso_a3 value as_of] + values.sort.each { |iso, v| csv << [iso, v.round(6), as_of] } + end) + end +end + +exit(DataSources.refresh_all.empty? ? 0 : 1) if $PROGRAM_NAME == __FILE__ diff --git a/skills/territory-pricing/scripts/schedule_body.rb b/skills/territory-pricing/scripts/schedule_body.rb new file mode 100755 index 0000000..df5eef1 --- /dev/null +++ b/skills/territory-pricing/scripts/schedule_body.rb @@ -0,0 +1,39 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Builds the request body that writes a Price Proposal of an app or an in-app +# purchase to App Store Connect. Local files only: it sends nothing. +# +# ruby schedule_body.rb --proposal DIR/proposal.json --kind app|iap --id PRODUCT_ID \ +# [--start-date YYYY-MM-DD] > DIR/body.json +# ruby asc.rb POST /v1/appPriceSchedules @DIR/body.json --write (after the user agreed) +# +# Without --start-date the prices take effect at once. With it, changed +# territories switch on that day (use the start date of the subscriptions so +# the whole family changes together). +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'optparse' +require 'json' +require 'time' +require 'schedule' + +opts = {} +OptionParser.new do |o| + o.on('--proposal FILE') { |v| opts[:proposal] = v } + o.on('--kind KIND', Schedule::KINDS.keys) { |v| opts[:kind] = v } + o.on('--id ID') { |v| opts[:id] = v } + o.on('--start-date DATE', 'YYYY-MM-DD, after today (UTC)') { |v| opts[:start] = v } +end.parse! +abort 'usage: see header of schedule_body.rb' unless opts.values_at(:proposal, :kind, :id).all? + +proposal = JSON.parse(File.read(opts[:proposal])) +begin + body = Schedule.body(kind: opts[:kind], product_id: opts[:id], base: proposal['meta']['base_territory'], + rows: proposal['rows'], start_date: opts[:start], today: Time.now.utc.to_date) +rescue ArgumentError => e + abort "ERROR: #{e.message}" +end +dated = body['included'].count { |i| i['attributes']['startDate'] } +warn "#{body['included'].size} price entries for #{proposal['rows'].count { |r| r['price_point_id'] }} territories" \ + "#{", #{dated} starting on #{opts[:start]}" if opts[:start]}" +puts JSON.pretty_generate(body) diff --git a/skills/territory-pricing/tests/asc_test.rb b/skills/territory-pricing/tests/asc_test.rb new file mode 100644 index 0000000..2c22543 --- /dev/null +++ b/skills/territory-pricing/tests/asc_test.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require_relative '../scripts/asc' +require 'open3' +require 'rbconfig' +require 'openssl' +require 'tmpdir' + +class AscTest < Minitest::Test + def with_key(pem) + Dir.mktmpdir do |dir| + path = File.join(dir, 'AuthKey_ABCDE12345.p8') + File.write(path, pem) + yield path + end + end + + def test_jwt_is_a_valid_es256_token + key = OpenSSL::PKey::EC.generate('prime256v1') + with_key(key.to_pem) do |path| + token = Asc.jwt(Asc::Credentials.new('ABCDE12345', 'issuer-uuid', path), 1_700_000_000) + header, payload, signature = token.split('.') + assert_equal({ 'alg' => 'ES256', 'kid' => 'ABCDE12345', 'typ' => 'JWT' }, JSON.parse(Base64.urlsafe_decode64(header))) + claims = JSON.parse(Base64.urlsafe_decode64(payload)) + assert_equal 'issuer-uuid', claims['iss'] + assert_equal 'appstoreconnect-v1', claims['aud'] + assert_equal 1_700_001_200, claims['exp'] + raw = Base64.urlsafe_decode64(signature) + assert_equal 64, raw.bytesize + der = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(OpenSSL::BN.new(raw[0, 32], 2)), + OpenSSL::ASN1::Integer(OpenSSL::BN.new(raw[32, 32], 2))]).to_der + assert key.verify(OpenSSL::Digest::SHA256.new, der, "#{header}.#{payload}") + end + end + + def test_rejects_keys_that_are_not_p256 + with_key(OpenSSL::PKey::EC.generate('secp384r1').to_pem) do |path| + assert_raises(Asc::Error) { Asc.private_key(path) } + end + end + + def test_rejects_non_ec_keys + with_key(OpenSSL::PKey::RSA.new(2048).to_pem) do |path| + assert_raises(Asc::Error) { Asc.private_key(path) } + end + end + + def test_path_encodes_brackets + assert_equal '/v1/x?filter%5Bterritory%5D=USA&limit=200', Asc.path('/v1/x', 'filter[territory]' => 'USA', 'limit' => 200) + end + + def test_path_extends_an_existing_query_with_an_ampersand + assert_equal '/v1/x?include=territory&limit=200', Asc.path('/v1/x?include=territory', 'limit' => 200) + end + + def test_token_is_never_sent_to_another_host + assert_raises(Asc::Error) { Asc.request(:get, 'https://example.com/v1/apps') } + end + + ASC = File.expand_path('../scripts/asc.rb', __dir__) + + def run_asc(*args) + Open3.capture3({ 'ASC_KEY_ID' => nil, 'ASC_ISSUER_ID' => nil, 'ASC_P8' => nil, 'HOME' => Dir.tmpdir }, RbConfig.ruby, ASC, *args) + end + + def test_writes_are_refused_without_the_write_flag + %w[POST PATCH DELETE].each do |method| + _, err, status = run_asc(method, '/v1/subscriptionPrices', '{}') + assert_equal 2, status.exitstatus, method + assert_includes err, '--write' + end + end + + def test_write_guard_runs_before_credentials_are_read + _, err, status = run_asc('POST', '/v1/x', '{}') + refute_includes err, 'AuthKey', 'no key lookup should happen for a refused write' + assert_equal 2, status.exitstatus + end + + def test_unknown_flags_and_methods_are_rejected + assert_equal 2, run_asc('GET', '/v1/apps', '--bogus').last.exitstatus + assert_equal 2, run_asc('PUT', '/v1/apps', '--write').last.exitstatus + end + + FakeResponse = Struct.new(:code, :body) do + def [](_header) + nil + end + end + + def with_stub(name, impl) + original = Asc.method(name) + Asc.singleton_class.send(:define_method, name, &impl) + yield + ensure + Asc.singleton_class.send(:define_method, name, original) + end + + def test_the_token_is_signed_once_when_threads_race + calls = Queue.new + Asc.instance_variable_set(:@token, nil) + Asc.instance_variable_set(:@token_at, nil) + with_stub(:credentials, proc { :creds }) do + with_stub(:jwt, proc { |*| calls << 1; sleep 0.05; "token-#{calls.size}" }) do + tokens = Array.new(20) { Thread.new { Asc.token } }.map(&:value) + assert_equal 1, calls.size + assert_equal ['token-1'], tokens.uniq + end + end + ensure + Asc.instance_variable_set(:@token, nil) + Asc.instance_variable_set(:@token_at, nil) + end + + def test_an_accepted_write_with_a_non_json_body_is_still_a_success + with_stub(:http_call, proc { |*| FakeResponse.new('201', 'proxy') }) do + result = Asc.request(:post, '/v1/appPriceSchedules', {}) + assert_includes result['note'], 'succeeded' + assert_includes result['body'], 'proxy' + end + end + + def test_a_get_with_a_non_json_body_is_an_error + with_stub(:http_call, proc { |*| FakeResponse.new('200', 'proxy') }) do + assert_raises(Asc::Error) { Asc.request(:get, '/v1/apps') } + end + end + + def test_a_malformed_request_body_is_refused_before_any_request + _, err, status = run_asc('POST', '/v1/x', '{bad', '--write') + assert_equal 2, status.exitstatus + assert_includes err, 'bad request body' + refute_includes err, 'AuthKey', 'no key lookup for a body that cannot be sent' + end +end diff --git a/skills/territory-pricing/tests/collect_points_test.rb b/skills/territory-pricing/tests/collect_points_test.rb new file mode 100644 index 0000000..88484e9 --- /dev/null +++ b/skills/territory-pricing/tests/collect_points_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require_relative '../scripts/asc' +require 'tmpdir' +require 'stringio' + +# collect_points.rb with Asc.paginate stubbed: batching, resume, failure handling. +class CollectPointsTest < Minitest::Test + SCRIPT = File.expand_path('../scripts/collect_points.rb', __dir__) + + def setup + @original = Asc.method(:paginate) + @requested = Queue.new + @fail = [] + requested = @requested + fail_on = @fail + Asc.singleton_class.send(:define_method, :paginate) do |target| + territory = target[/territory%5D=(\w+)/, 1] + requested << territory + raise Asc::Error, 'HTTP 500: boom' if fail_on.include?(territory) + + { 'data' => [{ 'id' => "#{territory}-1", 'attributes' => { 'customerPrice' => '9.99' } }] } + end + end + + def teardown + Asc.singleton_class.send(:define_method, :paginate, @original) + end + + def run_script(dir, batch: '40') + ENV['COLLECT_BATCH'] = batch + out = StringIO.new + status = nil + $stdout = out + begin + ARGV.replace([dir, '/v1/subscriptions/42/pricePoints']) + load SCRIPT + rescue SystemExit => e + status = e.status + ensure + $stdout = STDOUT + ENV.delete('COLLECT_BATCH') + end + [out.string, status] + end + + def with_run(territories) + Dir.mktmpdir do |dir| + File.write(File.join(dir, 'territories.json'), JSON.generate(territories)) + yield dir + end + end + + def test_one_batch_saves_that_many_territories_then_resumes + with_run(%w[USA BRA IND JPN DEU]) do |dir| + out, status = run_script(dir, batch: '2') + assert_equal 0, status + assert_includes out, '2/5 territories saved' + assert_includes out, 'run again: 3 left' + out, = run_script(dir, batch: '2') + assert_includes out, '4/5 territories saved' + out, = run_script(dir, batch: '2') + assert_includes out, 'done' + assert_equal 5, Dir[File.join(dir, 'points', '*.json')].size + end + end + + def test_saved_territories_are_not_requested_again + with_run(%w[USA BRA]) do |dir| + run_script(dir) + @requested.clear + run_script(dir) + assert_equal 0, @requested.size + end + end + + def test_points_are_stored_as_numbers + with_run(%w[USA]) do |dir| + run_script(dir) + assert_equal [{ 'id' => 'USA-1', 'price' => 9.99 }], JSON.parse(File.read(File.join(dir, 'points', 'USA.json'))) + end + end + + def test_an_unexpected_error_is_reported_like_any_other_failure + with_run(%w[USA BAD BRA]) do |dir| + Asc.singleton_class.send(:define_method, :paginate) do |target| + raise JSON::ParserError, 'unexpected token' if target.include?('territory%5D=BAD') + + { 'data' => [] } + end + out, status = run_script(dir) + assert_equal 1, status + assert_includes out, 'failed: BAD: JSON::ParserError: unexpected token' + assert_includes out, '2/3 territories saved' + end + end + + def test_a_failed_territory_is_reported_and_the_others_are_kept + with_run(%w[USA BAD BRA]) do |dir| + @fail << 'BAD' + out, status = run_script(dir) + assert_equal 1, status + assert_includes out, 'failed: BAD: HTTP 500: boom' + assert_includes out, '2/3 territories saved' + refute File.exist?(File.join(dir, 'points', 'BAD.json')) + @fail.clear + out, status = run_script(dir) + assert_equal 0, status + assert_includes out, 'done' + end + end +end diff --git a/skills/territory-pricing/tests/fixtures/plan_input.json b/skills/territory-pricing/tests/fixtures/plan_input.json new file mode 100644 index 0000000..127ecc5 --- /dev/null +++ b/skills/territory-pricing/tests/fixtures/plan_input.json @@ -0,0 +1,16 @@ +{ + "product": { "kind": "subscription", "id": "42", "name": "Pro Monthly" }, + "base_territory": "USA", + "territories": [ + { "territory": "USA", "currency": "USD", "current": 11.99, "current_point_id": "usa-legacy", "equalized": 11.99, + "points": [{ "id": "usa-9", "price": 9.99 }, { "id": "usa-11", "price": 11.99 }, { "id": "usa-14", "price": 14.99 }] }, + { "territory": "DEU", "currency": "EUR", "current": 12.99, "current_point_id": "deu-12", "equalized": 12.99, + "points": [{ "id": "deu-9", "price": 9.99 }, { "id": "deu-12", "price": 12.99 }] }, + { "territory": "BRA", "currency": "BRL", "current": 79.9, "current_point_id": "bra-79", "equalized": 79.9, + "points": [{ "id": "bra-34", "price": 34.9 }, { "id": "bra-49", "price": 49.9 }, { "id": "bra-79", "price": 79.9 }] }, + { "territory": "IND", "currency": "INR", "current": 999.0, "current_point_id": "ind-999", "equalized": 999.0, + "points": [{ "id": "ind-299", "price": 299.0 }, { "id": "ind-499", "price": 499.0 }, { "id": "ind-999", "price": 999.0 }] }, + { "territory": "ZZZ", "currency": "ZZD", "current": 5.0, "current_point_id": "zzz-5", "equalized": 5.0, "available": false, + "points": [{ "id": "zzz-5", "price": 5.0 }] } + ] +} diff --git a/skills/territory-pricing/tests/fixtures/tiers.json b/skills/territory-pricing/tests/fixtures/tiers.json new file mode 100644 index 0000000..f722ad3 --- /dev/null +++ b/skills/territory-pricing/tests/fixtures/tiers.json @@ -0,0 +1 @@ +{ "tier_ratios": { "1": 1.0, "2": 0.6, "3": 0.3 }, "default_tier": 1, "territories": { "BRA": 2, "IND": 3 } } diff --git a/skills/territory-pricing/tests/ladder_test.rb b/skills/territory-pricing/tests/ladder_test.rb new file mode 100644 index 0000000..ce052f8 --- /dev/null +++ b/skills/territory-pricing/tests/ladder_test.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require 'ladder' +require 'open3' +require 'rbconfig' +require 'tmpdir' + +class LadderTest < Minitest::Test + LADDER = File.expand_path('../scripts/ladder.rb', __dir__) + + def product(name, period, prices) + { 'name' => name, 'period' => period, 'prices' => prices, 'currencies' => prices.keys.map { |t| [t, 'USD'] }.to_h } + end + + def clean + [product('monthly', 'ONE_MONTH', 'USA' => 4.99, 'BRA' => 14.9), + product('yearly', 'ONE_YEAR', 'USA' => 39.99, 'BRA' => 119.9), + product('lifetime', 'LIFETIME', 'USA' => 99.99, 'BRA' => 299.0)] + end + + def rules(result) + result['violations'].map { |v| [v['territory'], v['rule']] } + end + + def test_a_clean_ladder_has_no_violations + result = Ladder.check(clean) + assert_empty result['violations'] + assert_equal 2, result['checked'] + end + + def test_yearly_above_lifetime_is_caught + products = clean + products[1]['prices']['BRA'] = 400.0 + assert_includes rules(Ladder.check(products)), %w[BRA lifetime_too_cheap] + end + + def test_lifetime_must_be_at_least_two_yearly_by_default + products = clean + products[2]['prices']['USA'] = 59.99 # above yearly, below 2x yearly + assert_equal [%w[USA lifetime_too_cheap]], rules(Ladder.check(products)) + end + + def test_the_lifetime_threshold_is_configurable + products = clean + products[2]['prices']['USA'] = 59.99 + assert_empty Ladder.check(products, min_lifetime_years: 1.0)['violations'] + end + + def test_yearly_below_monthly_is_caught_as_not_pricier + products = clean + products[1]['prices']['USA'] = 3.99 + assert_includes rules(Ladder.check(products)), %w[USA longer_period_not_pricier] + end + + def test_yearly_dearer_than_twelve_months_is_caught + products = clean + products[1]['prices']['USA'] = 61.0 # 12 x 4.99 = 59.88 + assert_equal [%w[USA longer_period_costs_more_per_month]], rules(Ladder.check(products, min_lifetime_years: 1.0)) + end + + def test_yearly_exactly_twelve_months_is_allowed + products = clean + products[1]['prices']['USA'] = 59.88 + assert_empty Ladder.check(products, min_lifetime_years: 1.0)['violations'] + end + + def test_a_weekly_plan_is_normalised_to_a_month + products = [product('weekly', 'ONE_WEEK', 'USA' => 1.99), product('monthly', 'ONE_MONTH', 'USA' => 4.99)] + assert_empty Ladder.check(products)['violations'], '4.99 a month is cheaper per month than 1.99 a week' + products[1]['prices']['USA'] = 9.99 # 4.33 weeks x 1.99 = 8.62 a month + assert_equal [%w[USA longer_period_costs_more_per_month]], rules(Ladder.check(products)) + end + + def test_three_and_six_month_plans_are_ordered_by_length + products = [product('3m', 'THREE_MONTHS', 'USA' => 12.99), product('6m', 'SIX_MONTHS', 'USA' => 20.99), + product('1m', 'ONE_MONTH', 'USA' => 4.99)] + assert_empty Ladder.check(products)['violations'] + end + + def test_territories_priced_in_one_product_only_are_skipped + products = clean + products[2]['prices'].delete('BRA') + products[1]['prices'].delete('BRA') + result = Ladder.check(products) + assert_equal 1, result['checked'] + assert_equal 1, result['skipped'] + end + + def test_lifetime_only_has_nothing_to_compare_with + assert_equal 0, Ladder.check([product('lifetime', 'LIFETIME', 'USA' => 99.0)])['checked'] + end + + def test_violations_carry_the_currency + products = clean + products[2]['prices']['USA'] = 10.0 + assert_equal 'USD', Ladder.check(products)['violations'].first['currency'] + end + + def test_an_unknown_period_is_rejected + assert_raises(ArgumentError) { Ladder.check([product('x', 'FORTNIGHT', 'USA' => 1.0), product('y', 'ONE_MONTH', 'USA' => 2.0)]) } + end + + # ---- the command line ---------------------------------------------------- + + def write_proposal(dir, name, period, prices) + path = File.join(dir, name) + FileUtils.mkdir_p(path) + rows = prices.map { |t, p| { 'territory' => t, 'currency' => 'USD', 'proposed' => p } } + meta = { 'product' => { 'name' => name, 'period' => period }.compact } + File.write(File.join(path, 'proposal.json'), JSON.generate('meta' => meta, 'rows' => rows)) + File.join(path, 'proposal.json') + end + + def run_ladder(*args) + Open3.capture3(RbConfig.ruby, LADDER, *args) + end + + def test_cli_exits_zero_when_the_ladder_is_clean + Dir.mktmpdir do |dir| + files = [write_proposal(dir, 'monthly', 'ONE_MONTH', 'USA' => 4.99), write_proposal(dir, 'yearly', 'ONE_YEAR', 'USA' => 39.99), + write_proposal(dir, 'lifetime', 'LIFETIME', 'USA' => 99.99)] + out, err, status = run_ladder(*files) + assert status.success?, err + assert_includes out, 'No violations.' + end + end + + def test_cli_exits_one_and_names_the_territory_on_a_violation + Dir.mktmpdir do |dir| + files = [write_proposal(dir, 'yearly', 'ONE_YEAR', 'USA' => 39.99, 'BRA' => 200.0), + write_proposal(dir, 'lifetime', 'LIFETIME', 'USA' => 99.99, 'BRA' => 150.0)] + out, _, status = run_ladder(*files) + assert_equal 1, status.exitstatus + assert_includes out, 'BRA' + assert_includes out, 'lifetime_too_cheap' + saved = JSON.parse(File.read(File.join(dir, 'yearly', 'ladder.json'))) + assert_equal 1, saved['violations'].size + end + end + + def test_cli_threshold_flag + Dir.mktmpdir do |dir| + files = [write_proposal(dir, 'yearly', 'ONE_YEAR', 'USA' => 39.99), write_proposal(dir, 'lifetime', 'LIFETIME', 'USA' => 60.0)] + assert_equal 1, run_ladder(*files).last.exitstatus + assert_equal 0, run_ladder(*files, '--lifetime-min-years', '1').last.exitstatus + end + end + + def test_cli_names_a_proposal_without_a_period + Dir.mktmpdir do |dir| + files = [write_proposal(dir, 'yearly', 'ONE_YEAR', 'USA' => 39.99), write_proposal(dir, 'lifetime', nil, 'USA' => 99.0)] + _, err, status = run_ladder(*files) + assert_equal 2, status.exitstatus + assert_includes err, 'lifetime/proposal.json' + assert_includes err, 'period' + end + end + + def test_cli_needs_two_proposals + assert_equal 2, run_ladder('only-one.json').last.exitstatus + end + + def test_two_plans_of_one_period_are_both_compared_with_the_longer_one + a = product('monthly A', 'ONE_MONTH', 'USA' => 10.0) + b = product('monthly B', 'ONE_MONTH', 'USA' => 5.0) + y = product('yearly', 'ONE_YEAR', 'USA' => 70.0) # 5.83 a month, dearer than B + [[a, b, y], [b, a, y], [y, b, a]].each do |order| + found = Ladder.check(order, min_lifetime_years: 1.0)['violations'] + assert(found.any? { |v| v['rule'] == 'longer_period_costs_more_per_month' && v['detail'].include?('monthly B') }, + order.map { |p| p['name'] }.join(', ')) + end + end +end diff --git a/skills/territory-pricing/tests/plan_test.rb b/skills/territory-pricing/tests/plan_test.rb new file mode 100644 index 0000000..d1aaf88 --- /dev/null +++ b/skills/territory-pricing/tests/plan_test.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require 'open3' +require 'rbconfig' +require 'tmpdir' +require 'csv' + +# plan.rb end to end on a fixture; reads the bundled snapshots, never the network. +class PlanTest < Minitest::Test + PLAN = File.expand_path('../scripts/plan.rb', __dir__) + INPUT = File.expand_path('fixtures/plan_input.json', __dir__) + TIERS = File.expand_path('fixtures/tiers.json', __dir__) + + def plan(dir, *args) + out, err, status = Open3.capture3(RbConfig.ruby, PLAN, '--out', dir, *args) + assert status.success?, "plan.rb failed: #{err}" + out + end + + def rows(dir) + JSON.parse(File.read(File.join(dir, 'proposal.json')))['rows'].map { |r| [r['territory'], r] }.to_h + end + + def test_tiered_proposal_snaps_to_listed_points + Dir.mktmpdir do |dir| + out = plan(dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', TIERS) + r = rows(dir) + assert_equal 'bra-49', r['BRA']['price_point_id'], '79.9 x 0.6 = 47.9 -> 49.9' + assert_equal 'ind-299', r['IND']['price_point_id'], '999 x 0.3 = 299.7 -> 299' + assert_equal 'deu-12', r['DEU']['price_point_id'] + assert_includes out, 'Price Proposal: subscription 42' + end + end + + def test_legacy_current_point_does_not_count_as_a_change + Dir.mktmpdir do |dir| + plan(dir, '--input', INPUT, '--strategy', 'equalization') + usa = rows(dir)['USA'] + assert_equal 'usa-11', usa['current_point_id'], 'legacy id is resolved by price' + refute Pricing.changed?(usa) + end + end + + def test_base_keeps_its_price_and_unavailable_is_skipped + Dir.mktmpdir do |dir| + plan(dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', TIERS) + r = rows(dir) + assert_equal 'base', r['USA']['ratio_source'] + assert_equal 11.99, r['USA']['proposed'] + assert_equal 'unavailable', r['ZZZ']['note'] + assert_equal 'zzz-5', r['ZZZ']['price_point_id'], 'a skipped territory keeps its current price point' + assert_nil r['ZZZ']['ratio_source'] + end + end + + def test_gni_bands_use_the_bundled_snapshot + Dir.mktmpdir do |dir| + out = plan(dir, '--input', INPUT, '--strategy', 'gni_bands') + r = rows(dir) + assert_equal 'gni_bands', r['IND']['ratio_source'] + assert_equal 0.3, r['IND']['ratio'] + assert_includes out, 'data gni: snapshot' + end + end + + def test_edited_csv_is_rebuilt_onto_a_listed_point + Dir.mktmpdir do |dir| + plan(dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', TIERS) + csv_path = File.join(dir, 'proposal.csv') + table = CSV.read(csv_path, headers: true) + table.each { |row| row['proposed'] = '45' if row['territory'] == 'BRA' } + File.write(csv_path, table.to_csv) + plan(dir, '--rebuild') + bra = rows(dir)['BRA'] + assert_equal 'bra-49', bra['price_point_id'], '45 snaps to 49.9' + assert_equal 'manual', bra['ratio_source'] + end + end + + def test_input_without_the_base_territory_is_rejected + Dir.mktmpdir do |dir| + _, err, status = Open3.capture3(RbConfig.ruby, PLAN, '--out', dir, '--input', INPUT, '--base', 'FRA', '--strategy', 'equalization') + refute status.success? + assert_includes err, 'FRA' + end + end + + def test_charm_endings_are_reported_and_saved + Dir.mktmpdir do |dir| + out = plan(dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', TIERS, '--endings', 'charm') + assert_includes out, 'Price endings: charm (within 5% of the target)' + options = JSON.parse(File.read(File.join(dir, 'proposal.json')))['meta']['options'] + assert_equal 'charm', options['endings'] + refute options.key?('endings_rules'), 'the rules file is not copied into the proposal' + end + end + + def test_endings_default_to_nearest + Dir.mktmpdir do |dir| + assert_includes plan(dir, '--input', INPUT, '--strategy', 'equalization'), 'Price endings: none' + end + end + + def test_unknown_ending_mode_is_rejected + Dir.mktmpdir do |dir| + _, err, status = Open3.capture3(RbConfig.ruby, PLAN, '--out', dir, '--input', INPUT, '--endings', 'fancy') + refute status.success? + assert_includes err, 'fancy' + end + end + + def with_tiers(json) + Dir.mktmpdir do |dir| + path = File.join(dir, 'tiers.json') + File.write(path, JSON.generate(json)) + yield path, dir + end + end + + def test_partial_tier_ratios_keep_the_defaults_for_the_rest + with_tiers('tier_ratios' => { '1' => 1.0, '2' => 0.5 }, 'territories' => { 'BRA' => 2, 'IND' => 3 }) do |tiers, dir| + plan(dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', tiers) + r = rows(dir) + assert_equal 0.4, r['IND']['ratio'], 'tier 3 falls back to its default ratio, not to full price' + assert_equal 0.5, r['BRA']['ratio'] + end + end + + def test_a_tier_without_any_ratio_is_an_error + with_tiers('territories' => { 'BRA' => 9 }) do |tiers, dir| + _, err, status = Open3.capture3(RbConfig.ruby, PLAN, '--out', dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', tiers) + refute status.success? + assert_includes err, 'tier 9' + end + end + + def test_a_narrow_clamp_leaves_the_base_alone + Dir.mktmpdir do |dir| + plan(dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', TIERS, '--clamp-max', '0.9') + assert_equal 11.99, rows(dir)['USA']['proposed'] + end + end + + def test_rebuild_keeps_the_options_of_the_original_run + Dir.mktmpdir do |dir| + plan(dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', TIERS, '--flag-threshold', '0.1') + csv_path = File.join(dir, 'proposal.csv') + table = CSV.read(csv_path, headers: true) + table.each { |row| row['proposed'] = '79.9' if row['territory'] == 'BRA' } + File.write(csv_path, table.to_csv) + plan(dir, '--rebuild') + bra = rows(dir)['BRA'] + assert_equal 79.9, bra['proposed'] + assert_equal 0.1, JSON.parse(File.read(File.join(dir, 'proposal.json')))['meta']['options']['flag_threshold'] + assert_includes rows(dir)['IND']['flag'], 'large-change', 'untouched rows keep the stored threshold too' + end + end + + def test_rebuild_with_an_unreadable_cell_changes_nothing + Dir.mktmpdir do |dir| + plan(dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', TIERS) + before = File.read(File.join(dir, 'proposal.json')) + csv_path = File.join(dir, 'proposal.csv') + table = CSV.read(csv_path, headers: true) + table.each { |row| row['proposed'] = 'about ten' if row['territory'] == 'BRA' } + File.write(csv_path, table.to_csv) + _, err, status = Open3.capture3(RbConfig.ruby, PLAN, '--out', dir, '--rebuild') + assert_equal 2, status.exitstatus + assert_includes err, "BRA: 'about ten'" + assert_equal before, File.read(File.join(dir, 'proposal.json')) + end + end + + def test_a_comma_decimal_edit_survives_a_rebuild + Dir.mktmpdir do |dir| + plan(dir, '--input', INPUT, '--strategy', 'tiered', '--tiers', TIERS) + csv_path = File.join(dir, 'proposal.csv') + table = CSV.read(csv_path, headers: true) + table.each { |row| row['proposed'] = '35,0' if row['territory'] == 'BRA' } + File.write(csv_path, table.to_csv) + plan(dir, '--rebuild') + assert_equal 'bra-34', rows(dir)['BRA']['price_point_id'] + end + end +end + diff --git a/skills/territory-pricing/tests/pricing_test.rb b/skills/territory-pricing/tests/pricing_test.rb new file mode 100644 index 0000000..89e8359 --- /dev/null +++ b/skills/territory-pricing/tests/pricing_test.rb @@ -0,0 +1,377 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +class PricingTest < Minitest::Test + OPTS = Pricing.options(base_territory: 'USA') + DATA = { + big_mac: { 'USA' => 6.0, 'BRA' => 3.0, 'JPN' => 3.0 }, + ppp: { 'USA' => 1.0, 'BRA' => 0.46, 'IND' => 0.23, 'TUR' => 0.45 }, + gni: { 'USA' => 88_000.0, 'BRA' => 10_000.0, 'IND' => 2_500.0, 'DEU' => 60_000.0 }, + band1_overrides: ['JPN'], + tiers: { 'BRA' => 2, 'IND' => 3 } + }.freeze + + def points(*prices) + prices.each_with_index.map { |p, i| { 'id' => "pp#{i}", 'price' => p.to_f } } + end + + def input(territory, equalized, pts, extra = {}) + { 'territory' => territory, 'currency' => 'XXX', 'current' => equalized, 'current_point_id' => 'old', + 'equalized' => equalized, 'available' => true, 'points' => pts }.merge(extra) + end + + def test_gni_bands_follow_income_thresholds + assert_equal [1.0, 'gni_bands'], Pricing.ratio_for('gni_bands', 'DEU', DATA, OPTS) + assert_equal [0.6, 'gni_bands'], Pricing.ratio_for('gni_bands', 'BRA', DATA, OPTS) + assert_equal [0.3, 'gni_bands'], Pricing.ratio_for('gni_bands', 'IND', DATA, OPTS) + end + + def test_band_one_override_beats_income + assert_equal [1.0, 'gni_bands'], Pricing.ratio_for('gni_bands', 'JPN', DATA, OPTS) + end + + def test_big_mac_is_relative_to_the_base_territory + ratio, source = Pricing.ratio_for('big_mac', 'BRA', DATA, OPTS) + assert_in_delta 0.5, ratio + assert_equal 'big_mac', source + end + + def test_big_mac_falls_back_to_ppp_then_equalization + assert_equal [0.45, 'ppp'], Pricing.ratio_for('big_mac', 'TUR', DATA, OPTS) + assert_equal [1.0, 'equalization'], Pricing.ratio_for('big_mac', 'ZZZ', DATA, OPTS) + end + + def test_missing_gni_falls_back_to_equalization + assert_equal [1.0, 'equalization'], Pricing.ratio_for('gni_bands', 'ZZZ', DATA, OPTS) + end + + def test_ppp_uses_a_non_us_base + opts = Pricing.options(base_territory: 'BRA') + ratio, = Pricing.ratio_for('ppp', 'IND', DATA, opts) + assert_in_delta 0.5, ratio + end + + def test_tiered_uses_tier_ratios_and_default_tier + assert_equal [0.7, 'tiered'], Pricing.ratio_for('tiered', 'BRA', DATA, OPTS) + assert_equal [0.4, 'tiered'], Pricing.ratio_for('tiered', 'IND', DATA, OPTS) + assert_equal [1.0, 'tiered'], Pricing.ratio_for('tiered', 'DEU', DATA, OPTS) + end + + def test_equalization_strategy_changes_nothing + assert_equal [1.0, 'equalization'], Pricing.ratio_for('equalization', 'BRA', DATA, OPTS) + end + + def test_clamp_keeps_ratio_inside_bounds + assert_equal 0.30, Pricing.clamp_ratio(0.05, OPTS) + assert_equal 1.30, Pricing.clamp_ratio(2.0, OPTS) + assert_equal 0.8, Pricing.clamp_ratio(0.8, OPTS) + end + + def test_nearest_point_and_cheaper_on_tie + assert_equal 'pp1', Pricing.nearest_point(4.6, points(2.99, 4.99, 9.99))['id'] + assert_equal 'pp0', Pricing.nearest_point(4.0, points(3.0, 5.0))['id'] + end + + def test_row_snaps_to_a_real_price_point_and_flags_big_changes + row = Pricing.build_row(input('IND', 1000.0, points(100, 299, 349, 999), 'current' => 999.0), 'gni_bands', DATA, OPTS) + assert_equal 299.0, row['proposed'] + assert_equal 'gni_bands', row['ratio_source'] + assert_equal 'large-change', row['flag'] + assert_in_delta(-0.7007, row['change_pct'], 0.0001) + end + + def test_row_marks_clamped_ratio + data = DATA.merge(ppp: { 'USA' => 1.0, 'XXX' => 0.05 }) + row = Pricing.build_row(input('XXX', 100.0, points(30, 50, 100)), 'ppp', data, OPTS) + assert row['clamped'] + assert_equal 0.3, row['ratio'] + assert_equal 30.0, row['proposed'] + assert_includes row['flag'], 'clamped' + end + + def test_base_territory_keeps_its_price + row = Pricing.build_row(input('USA', 9.99, points(4.99, 9.99, 14.99)), 'gni_bands', DATA, OPTS) + assert_equal 'base', row['ratio_source'] + assert_equal 9.99, row['proposed'] + end + + def test_unavailable_territory_is_skipped_and_keeps_its_price + row = Pricing.build_row(input('BRA', 10.0, points(5), 'available' => false, 'current_point_id' => 'pp-manual'), + 'gni_bands', DATA, OPTS) + assert Pricing.skipped?(row) + assert_equal 'unavailable', row['note'] + assert_equal 'pp-manual', row['price_point_id'], 'the schedule must not drop a manual price' + assert_equal 10.0, row['proposed'] + refute Pricing.changed?(row) + end + + def test_every_skip_reason_keeps_the_current_price + [input('BRA', 10.0, []), input('BRA', nil, points(1)), input('BRA', 10.0, points(5), 'available' => false)].each do |i| + row = Pricing.build_row(i.merge('current_point_id' => 'keep'), 'ppp', DATA, OPTS) + assert_equal 'keep', row['price_point_id'], row['note'] + refute Pricing.changed?(row), row['note'] + end + end + + def test_a_skipped_row_without_a_current_price_has_nothing_to_keep + row = Pricing.build_row(input('BRA', 10.0, [], 'current' => nil, 'current_point_id' => nil), 'ppp', DATA, OPTS) + assert_nil row['price_point_id'] + assert Pricing.skipped?(row) + end + + def test_the_base_price_is_never_clamped + opts = Pricing.options(clamp_max: 0.9) + row = Pricing.build_row(input('USA', 9.99, points(8.99, 9.99)), 'gni_bands', DATA, opts) + assert_equal 9.99, row['proposed'] + refute row['clamped'] + assert_equal 1.0, row['ratio'] + end + + def test_parse_price_reads_locale_formats + { + '9,90' => 9.9, '$4.99' => 4.99, '1,299.00' => 1299.0, '1.299,00' => 1299.0, + '1 299' => 1299.0, '1,299' => 1299.0, '12,5' => 12.5, '9990' => 9990.0, '₹499' => 499.0 + }.each { |raw, price| assert_equal price, Pricing.parse_price(raw), raw } + ['abc', '', '0', '-', nil].each { |raw| assert_nil Pricing.parse_price(raw), raw.inspect } + end + + def test_a_comma_decimal_edit_is_applied + rows = Pricing.build_proposal([input('BRA', 10.0, points(3, 6, 10), 'current' => 10.0)], 'gni_bands', DATA, OPTS) + csv = Pricing.to_csv(rows).sub(/,6\.0,-0\.4,/, ',"3,2",-0.4,') + out = Pricing.apply_csv_edits(rows, csv, { 'BRA' => points(3, 6, 10) }, OPTS) + assert_equal 3.0, out.first['proposed'] + end + + # sets the `proposed` cell of the given territories in a proposal.csv + def edit_csv(rows, edits) + table = CSV.parse(Pricing.to_csv(rows), headers: true) + table.each { |r| r['proposed'] = edits[r['territory']] if edits.key?(r['territory']) } + table.to_csv + end + + def test_unreadable_edits_raise_with_every_territory_named_and_change_nothing + inputs = [input('BRA', 10.0, points(3, 6, 10), 'current' => 10.0), input('IND', 10.0, points(3, 6, 10), 'current' => 10.0)] + rows = Pricing.build_proposal(inputs, 'gni_bands', DATA, OPTS) + csv = edit_csv(rows, 'BRA' => 'abc', 'IND' => 'n/a') + error = assert_raises(Pricing::EditError) { Pricing.apply_csv_edits(rows, csv, { 'BRA' => points(3, 6, 10) }, OPTS) } + assert_equal ["BRA: 'abc'", "IND: 'n/a'"], error.problems + end + + def test_an_emptied_cell_leaves_no_stale_numbers + rows = Pricing.build_proposal([input('BRA', 10.0, points(3, 6, 10), 'current' => 10.0)], 'gni_bands', DATA, OPTS) + csv = Pricing.to_csv(rows).sub(/,6\.0,-0\.4,/, ',,-0.4,') + row = Pricing.apply_csv_edits(rows, csv, { 'BRA' => points(3, 6, 10) }, OPTS).first + assert_equal 0.0, row['change_pct'] + assert_equal '', row['flag'] + assert_nil row['target'] + assert_equal 'unchanged', row['ratio_source'] + end + + def test_a_manual_edit_drops_the_clamped_flag + data = DATA.merge(ppp: { 'USA' => 1.0, 'XXX' => 0.05 }) + rows = Pricing.build_proposal([input('XXX', 100.0, points(30, 50, 100))], 'ppp', data, OPTS) + assert rows.first['clamped'] + csv = Pricing.to_csv(rows).sub(/,30\.0,-0\.7,/, ',50,-0.7,') + row = Pricing.apply_csv_edits(rows, csv, { 'XXX' => points(30, 50, 100) }, OPTS).first + refute row['clamped'] + refute_includes row['flag'], 'clamped' + assert_equal 50.0, row['target'] + end + + def test_territory_without_price_points_or_equalization_is_skipped + assert_equal 'no price points', Pricing.build_row(input('BRA', 10.0, []), 'ppp', DATA, OPTS)['note'] + assert_equal 'no equalized price', Pricing.build_row(input('BRA', nil, points(1)), 'ppp', DATA, OPTS)['note'] + end + + def test_changed_compares_point_ids + rows = Pricing.build_proposal([input('BRA', 10.0, points(6, 10), 'current_point_id' => 'pp0')], 'gni_bands', DATA, OPTS) + refute Pricing.changed?(rows.first) + rows = Pricing.build_proposal([input('BRA', 10.0, points(6, 10), 'current_point_id' => 'pp1')], 'gni_bands', DATA, OPTS) + assert Pricing.changed?(rows.first) + end + + def test_legacy_current_point_is_resolved_by_price + listed = points(9.99, 11.99, 14.99) + assert_equal 'pp1', Pricing.resolve_point_id('legacy-24', 11.99, listed) + assert_equal 'pp1', Pricing.resolve_point_id('pp1', 11.99, listed), 'a listed id is kept' + assert_equal 'legacy-24', Pricing.resolve_point_id('legacy-24', 12.49, listed), 'no price match keeps the id' + assert_nil Pricing.resolve_point_id(nil, nil, listed) + end + + def test_unchanged_price_on_a_legacy_point_is_not_a_change + listed = points(6, 10) + resolved = Pricing.resolve_point_id('legacy', 10.0, listed) + rows = Pricing.build_proposal([input('BRA', 10.0, listed, 'current_point_id' => resolved)], 'equalization', DATA, OPTS) + refute Pricing.changed?(rows.first) + end + + def test_csv_round_trip_and_manual_edit_resnaps + rows = Pricing.build_proposal([input('BRA', 10.0, points(3, 6, 10), 'current' => 10.0)], 'gni_bands', DATA, OPTS) + csv = Pricing.to_csv(rows) + assert_equal Pricing::CSV_COLUMNS, csv.lines.first.chomp.split(',') + edited = csv.sub(/^BRA,XXX,10\.0,10\.0,gni_bands,gni_bands,0\.6,false,6\.0,6\.0/, 'BRA,XXX,10.0,10.0,gni_bands,gni_bands,0.6,false,6.0,3.2') + refute_equal csv, edited + out = Pricing.apply_csv_edits(rows, edited, { 'BRA' => points(3, 6, 10) }, OPTS) + assert_equal 3.0, out.first['proposed'] + assert_equal 'manual', out.first['ratio_source'] + end + + def test_emptied_proposed_cell_leaves_territory_unchanged + rows = Pricing.build_proposal([input('BRA', 10.0, points(3, 6, 10), 'current' => 10.0)], 'gni_bands', DATA, OPTS) + csv = Pricing.to_csv(rows).sub(/,6\.0,-0\.4,/, ',,-0.4,') + out = Pricing.apply_csv_edits(rows, csv, { 'BRA' => points(3, 6, 10) }, OPTS) + refute Pricing.changed?(out.first) + assert_equal 'left unchanged by user', out.first['note'] + end + + def test_summary_counts + rows = Pricing.build_proposal( + [input('BRA', 10.0, points(6, 10), 'current' => 10.0), input('ZZZ', 10.0, [], 'current' => 10.0)], + 'gni_bands', DATA, OPTS + ) + s = Pricing.summary(rows) + assert_equal 2, s['territories'] + assert_equal 1, s['priced'] + assert_equal 1, s['changed'] + assert_equal 1, s['decreases'] + assert_equal 1, s['skipped'] + end + + # ---- price endings ------------------------------------------------------ + + CHARM = Pricing.options(endings: 'charm', endings_rules: { + 'BRL' => { 'unit' => 100, 'mod' => 100, 'endings' => [90, 99] }, + 'JPY' => { 'unit' => 1, 'mod' => 100, 'endings' => [80, 0] }, + 'MXN' => { 'unit' => 1, 'mod' => 10, 'endings' => [9] } + }) + + def pick(target, prices, currency, opts = CHARM) + point, note = Pricing.pick_point(target, points(*prices), currency, opts) + [point['price'], note] + end + + def test_endings_none_keeps_the_nearest_point + assert_equal 6.88, pick(6.9, [6.88, 6.99, 7.05], 'USD', Pricing.options)[0] + end + + def test_charm_prefers_a_99_ending_over_a_nearer_plain_price + assert_equal [6.99, ''], pick(6.9, [6.88, 6.99, 7.05], 'USD') + end + + def test_charm_accepts_49_as_well_as_99 + assert_equal 7.49, pick(7.5, [7.4, 7.49, 7.6], 'EUR')[0] + end + + def test_charm_uses_the_local_convention_in_brazil + assert_equal 64.9, pick(64.7, [64.5, 64.9, 65.0, 66.0], 'BRL')[0], 'BRL likes .90, not .00' + assert_equal 64.99, pick(64.7, [64.5, 64.99, 65.0], 'BRL')[0] + end + + def test_charm_in_japan_prefers_80_or_00_endings + prices = (150..160).map { |i| i * 10 } + assert_equal 1500, pick(1530, prices, 'JPY')[0] + assert_equal 1580, pick(1570, prices, 'JPY')[0] + end + + def test_charm_in_mexico_prefers_prices_ending_in_9 + assert_equal 249, pick(247, [240, 245, 249, 250], 'MXN')[0] + end + + def test_whole_number_currency_without_a_rule_prefers_round_numbers + prices = (160..170).map { |i| i * 10 } + assert_equal 1600, pick(1630, prices, 'XYZ')[0], 'two significant digits' + end + + def test_charm_stays_inside_the_tolerance + price, note = pick(7.0, [6.5, 7.3, 7.9], 'USD') + assert_equal 7.3, price, 'nearest wins when nothing nice is within 5%' + assert_equal 'no charm ending within 5%', note + assert_equal 6.99, pick(7.0, [6.99, 7.3], 'USD', Pricing.options(CHARM.merge(endings_tolerance: 0.01)))[0] + end + + def test_charm_may_dip_below_the_clamp_floor_within_the_tolerance + # band 3 sits exactly on the clamp floor; the nice 3.49 just below a 3.60 target must still be reachable + assert_equal [3.49, ''], pick(3.6, [3.49, 3.59, 3.69], 'USD') + end + + def test_charm_leaves_the_base_territory_alone + pts = points(9.99, 10.0, 10.49) + row = Pricing.build_row(input('USA', 10.0, pts, 'current' => 10.0), 'gni_bands', DATA, CHARM) + assert_equal 10.0, row['proposed'] + assert_equal 'base', row['ratio_source'] + end + + def test_charm_in_a_full_row + row = Pricing.build_row(input('BRA', 79.9, points(34.9, 47.0, 47.9, 49.9, 79.9), 'currency' => 'BRL'), 'gni_bands', DATA, CHARM) + assert_equal 47.9, row['proposed'], 'target 47.94: 47.90 is nice for BRL and nearest' + assert_equal '', row['note'] + end + + # The shipped rules file must make sense on the price points Apple really lists. + SHIPPED = Pricing.options(endings: 'charm', + endings_rules: JSON.parse(File.read(File.expand_path('../references/data/endings.json', __dir__)))['currencies']) + + def test_shipped_rules_match_local_conventions + assert_equal 17.98, pick(17.9, [17.5, 17.9, 17.95, 17.98, 18.0], 'SGD', SHIPPED)[0], 'SGD uses .98' + assert_equal 39.9, pick(39.7, [39.5, 39.7, 39.9, 40.0], 'ILS', SHIPPED)[0], 'ILS uses .90' + assert_equal 88.0, pick(88.4, [87.0, 88.0, 88.8, 89.0], 'CNY', SHIPPED)[0], 'CNY likes an 8' + assert_equal 349.0, pick(359.7, [345.0, 349.0, 350.0, 359.0, 360.0], 'INR', SHIPPED)[0], 'INR uses 49 and 99' + assert_equal 19_000.0, pick(19_100, [18_900, 19_000, 19_100, 19_200], 'KRW', SHIPPED)[0], 'KRW prefers round thousands' + assert_equal 9.9, pick(9.8, [9.5, 9.8, 9.9, 10.0], 'CHF', SHIPPED)[0], 'CHF uses .90' + end + + # ---- second review ---------------------------------------------------- + + def test_an_untouched_skipped_row_survives_a_rebuild_of_another_row + inputs = [input('BRA', 10.0, points(5), 'available' => false, 'current_point_id' => 'cur'), + input('IND', 10.0, points(3, 6, 10), 'current' => 10.0)] + rows = Pricing.build_proposal(inputs, 'gni_bands', DATA, OPTS) + out = Pricing.apply_csv_edits(rows, edit_csv(rows, 'IND' => '3'), { 'BRA' => points(5, 9.5), 'IND' => points(3, 6, 10) }, OPTS) + bra = out.find { |r| r['territory'] == 'BRA' } + assert Pricing.skipped?(bra) + assert_equal 'unavailable', bra['note'] + assert_equal 'cur', bra['price_point_id'] + refute Pricing.changed?(bra) + assert_equal 3.0, out.find { |r| r['territory'] == 'IND' }['proposed'] + end + + def test_a_three_decimal_price_written_by_the_script_is_left_alone + rows = Pricing.build_proposal([input('KWT', 1.099, points(1.099, 1.5), 'current' => 1.099), + input('IND', 10.0, points(3, 6, 10), 'current' => 10.0)], 'equalization', DATA, OPTS) + pts = { 'KWT' => points(1.099, 1.5, 999), 'IND' => points(3, 6, 10) } + [edit_csv(rows, 'IND' => '3'), edit_csv(rows, 'IND' => '3', 'KWT' => '1,099')].each do |csv| + kwt = Pricing.apply_csv_edits(rows, csv, pts, OPTS).find { |r| r['territory'] == 'KWT' } + assert_equal 1.099, kwt['proposed'] + assert_equal 'equalization', kwt['ratio_source'] + end + end + + def test_parse_price_resolves_three_digits_by_the_row + assert_equal 1.099, Pricing.parse_price('1.099', near: 1.2) + assert_equal 1099.0, Pricing.parse_price('1.099', near: 1300) + assert_equal 1099.0, Pricing.parse_price('1.099') + assert_equal 1.299, Pricing.parse_price('1,299', near: 1.3) + assert_equal 9.9, Pricing.parse_price('9,90', near: 1000), 'two decimals are never ambiguous' + end + + def test_a_free_point_is_never_chosen + assert_equal 0.29, Pricing.nearest_point(0.09, points(0, 0.29, 0.39))['price'] + price, = pick(0.09, [0, 0.29, 0.39], 'USD') + assert_equal 0.29, price + row = Pricing.build_row(input('IND', 1.0, points(0), 'current_point_id' => 'keep'), 'ppp', DATA, OPTS) + assert_equal 'no paid price points', row['note'] + assert_equal 'keep', row['price_point_id'] + end + + def test_the_base_ignores_a_free_point_too + row = Pricing.build_row(input('USA', 0.29, points(0, 0.29)), 'gni_bands', DATA, OPTS) + assert_equal 0.29, row['proposed'] + end + + def test_persisted_options_drop_the_transient_keys + kept = Pricing.persisted_options(CHARM) + Pricing::TRANSIENT_OPTIONS.each { |key| refute kept.key?(key), key } + assert_equal 'charm', kept[:endings] + end +end diff --git a/skills/territory-pricing/tests/refresh_data_test.rb b/skills/territory-pricing/tests/refresh_data_test.rb new file mode 100644 index 0000000..47563ee --- /dev/null +++ b/skills/territory-pricing/tests/refresh_data_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require 'stringio' +require_relative '../scripts/refresh_data' + +# refresh_data.rb with the network stubbed. +class RefreshDataTest < Minitest::Test + COUNTRIES = JSON.generate([{}, [{ 'id' => 'BRA', 'region' => { 'id' => 'LCN' } }, { 'id' => 'WLD', 'region' => { 'id' => 'NA' } }]]) + INDICATOR = JSON.generate([{}, [{ 'countryiso3code' => 'BRA', 'date' => '2024', 'value' => 1.5 }, + { 'countryiso3code' => 'WLD', 'date' => '2024', 'value' => 9.9 }]]) + + def setup + @original = DataSources.method(:get) + DataSources.instance_variable_set(:@country_codes, nil) + @urls = [] + urls = @urls + DataSources.singleton_class.send(:define_method, :get) do |url, *| + urls << url + url.include?('/country?') ? COUNTRIES : INDICATOR + end + end + + def teardown + DataSources.singleton_class.send(:define_method, :get, @original) + DataSources.instance_variable_set(:@country_codes, nil) + end + + def test_the_country_list_is_fetched_once_for_all_indicators + DataSources.world_bank('PA.NUS.PRVT.PP') + DataSources.world_bank('PA.NUS.FCRF') + DataSources.world_bank('NY.GNP.PCAP.CD') + assert_equal 1, @urls.count { |u| u.include?('/country?') } + assert_equal 3, @urls.count { |u| u.include?('/indicator/') } + end + + def test_aggregates_are_dropped + assert_equal({ 'BRA' => { 2024 => 1.5 } }, DataSources.world_bank('PA.NUS.PRVT.PP')) + end + + def test_snapshot_paths_come_from_the_shared_module + assert_equal Snapshots::DATA_DIR, File.expand_path('../references/data', __dir__) + assert_equal %i[big_mac ppp gni], Snapshots::FILES.keys + end + + def test_one_failed_source_does_not_stop_the_others + written = [] + stubs = { + fetch_big_mac: proc { raise 'HTTP 404 from raw.githubusercontent.com' }, + fetch_ppp: proc { [{ 'BRA' => 0.46 }, { 'as_of' => 2025 }] }, + fetch_gni: proc { [{ 'BRA' => 10_550.0 }, { 'as_of' => 2025 }] }, + write_snapshot: proc { |name, *| written << name } + } + originals = stubs.keys.map { |name| [name, DataSources.method(name)] }.to_h + stubs.each { |name, impl| DataSources.singleton_class.send(:define_method, name, &impl) } + out = StringIO.new + assert_equal [:big_mac], DataSources.refresh_all(out) + assert_equal %i[ppp gni], written + assert_includes out.string, 'big_mac: FAILED' + ensure + originals.each { |name, method| DataSources.singleton_class.send(:define_method, name, method) } + end +end diff --git a/skills/territory-pricing/tests/run_all.rb b/skills/territory-pricing/tests/run_all.rb new file mode 100644 index 0000000..5e8b3a7 --- /dev/null +++ b/skills/territory-pricing/tests/run_all.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# ruby tests/run_all.rb +Dir[File.join(__dir__, '*_test.rb')].sort.each { |f| require f } diff --git a/skills/territory-pricing/tests/schedule_test.rb b/skills/territory-pricing/tests/schedule_test.rb new file mode 100644 index 0000000..3665859 --- /dev/null +++ b/skills/territory-pricing/tests/schedule_test.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require 'schedule' +require 'open3' +require 'rbconfig' +require 'tmpdir' + +class ScheduleTest < Minitest::Test + TODAY = Date.new(2026, 10, 30) + START = '2026-11-15' + SCRIPT = File.expand_path('../scripts/schedule_body.rb', __dir__) + + def row(territory, new_point, current_point) + { 'territory' => territory, 'price_point_id' => new_point, 'current_point_id' => current_point } + end + + def rows + [row('USA', 'usa-new', 'usa-old'), row('DEU', 'deu-same', 'deu-same'), row('BRA', 'bra-new', nil), row('ZZZ', nil, nil)] + end + + def build(kind, start_date = nil, input = rows) + Schedule.body(kind: kind, product_id: '77', base: 'USA', rows: input, start_date: start_date, today: TODAY) + end + + def prices(body) + body['included'].map { |i| [i['relationships'].values.first['data']['id'], i['attributes']['startDate'], i['attributes']['endDate']] } + end + + def test_immediate_app_body_has_one_price_per_priced_territory + body = build('app') + assert_equal 'appPriceSchedules', body['data']['type'] + assert_equal 'USA', body['data']['relationships']['baseTerritory']['data']['id'] + assert_equal '77', body['data']['relationships']['app']['data']['id'] + assert_equal [['usa-new', nil, nil], ['deu-same', nil, nil], ['bra-new', nil, nil]], prices(body) + end + + def test_manual_prices_reference_every_included_price + body = build('app', START) + refs = body['data']['relationships']['manualPrices']['data'] + assert_equal body['included'].map { |i| i['id'] }, refs.map { |r| r['id'] } + assert_equal body['included'].size, refs.map { |r| r['id'] }.uniq.size + assert(refs.all? { |r| r['type'] == 'appPrices' }) + end + + def test_dated_app_body_keeps_the_current_price_until_the_start_date + assert_equal [['usa-old', nil, START], ['usa-new', START, nil], ['deu-same', nil, nil], ['bra-new', START, nil]], + prices(build('app', START)) + end + + def test_dated_iap_body_uses_a_start_date_pair + body = build('iap', START) + assert_equal 'inAppPurchasePriceSchedules', body['data']['type'] + assert_equal 'inAppPurchases', body['data']['relationships']['inAppPurchase']['data']['type'] + assert_equal [['usa-old', nil, nil], ['usa-new', START, nil], ['deu-same', nil, nil], ['bra-new', START, nil]], + body['included'].map { |i| [i['relationships']['inAppPurchasePricePoint']['data']['id'], i['attributes']['startDate'], i['attributes']['endDate']] } + refute(body['included'].any? { |i| i['attributes'].key?('endDate') }, 'IAP prices have no endDate in Apple\'s example') + end + + def test_iap_prices_name_their_product + price = build('iap').fetch('included').first + assert_equal 'inAppPurchasePrices', price['type'] + assert_equal '77', price['relationships']['inAppPurchaseV2']['data']['id'] + end + + def test_unchanged_territories_get_one_immediate_price_even_with_a_start_date + assert_equal [['deu-same', nil, nil]], prices(build('app', START, [row('DEU', 'deu-same', 'deu-same')])) + end + + def test_skipped_rows_keep_their_current_point + skipped = { 'territory' => 'ZZZ', 'price_point_id' => 'zzz-keep', 'current_point_id' => 'zzz-keep', 'ratio_source' => nil } + assert_equal [['zzz-keep', nil, nil]], prices(build('app', START, [skipped])) + end + + def test_rows_without_a_price_point_are_left_out + assert_empty build('app', nil, [row('ZZZ', nil, nil)])['included'] + end + + def test_the_start_date_must_be_after_today + [TODAY.to_s, '2026-10-01'].each do |date| + error = assert_raises(ArgumentError) { build('app', date) } + assert_includes error.message, 'after' + end + end + + def test_a_malformed_start_date_is_rejected + assert_includes assert_raises(ArgumentError) { build('app', '15.11.2026') }.message, 'YYYY-MM-DD' + end + + def test_unknown_kind_is_rejected + assert_raises(ArgumentError) { build('subscription') } + end + + # ---- the command line ---------------------------------------------------- + + def with_proposal + Dir.mktmpdir do |dir| + path = File.join(dir, 'proposal.json') + File.write(path, JSON.generate('meta' => { 'base_territory' => 'USA' }, 'rows' => rows)) + yield path + end + end + + def test_cli_prints_the_body_and_summarises_on_stderr + with_proposal do |path| + out, err, status = Open3.capture3(RbConfig.ruby, SCRIPT, '--proposal', path, '--kind', 'app', '--id', '77') + assert status.success?, err + assert_equal 3, JSON.parse(out)['included'].size + assert_includes err, '3 price entries' + end + end + + def test_cli_rejects_a_past_start_date + with_proposal do |path| + _, err, status = Open3.capture3(RbConfig.ruby, SCRIPT, '--proposal', path, '--kind', 'iap', '--id', '77', '--start-date', '2020-01-01') + refute status.success? + assert_includes err, 'after' + end + end +end diff --git a/skills/territory-pricing/tests/test_helper.rb b/skills/territory-pricing/tests/test_helper.rb new file mode 100644 index 0000000..46b9f88 --- /dev/null +++ b/skills/territory-pricing/tests/test_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path('../scripts/lib', __dir__)) +require 'minitest/autorun' +require 'json' +require 'pricing'