From 7786ed178b9485d0a32e5da7be92169deba3289e Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 30 Oct 2026 13:22:42 +0300 Subject: [PATCH 1/9] feat: add the territory-pricing skill Add the app-store-suite plugin with a skill that sets worldwide per-territory prices for an app, in-app purchase or subscription group in App Store Connect. - add pricing strategies: GNI bands, PPP, Big Mac index, tiered and Apple equalization, with a fallback chain and clamp guard rails - add check_setup.sh to verify the API key, its permissions and a live round trip (distinguishes 401 from 403) - build a Price Proposal first; apply.rb is a dry run unless --confirm, runs subscriptions as canary then rest, reads prices back after every write and supports rollback - bundle World Bank and Economist data snapshots as an offline fallback - add minitest suite and a test workflow; exclude the data CSVs from codespell --- .claude-plugin/marketplace.json | 10 + .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 31 ++ README.md | 1 + skills/territory-pricing/SKILL.md | 160 ++++++++++ .../references/asc-pricing-api.md | 46 +++ .../references/data/big-mac.csv | 76 +++++ .../references/data/gni-bands.json | 4 + .../territory-pricing/references/data/gni.csv | 203 +++++++++++++ .../territory-pricing/references/data/ppp.csv | 204 +++++++++++++ skills/territory-pricing/references/setup.md | 56 ++++ .../references/strategies.md | 87 ++++++ skills/territory-pricing/scripts/apply.rb | 176 +++++++++++ skills/territory-pricing/scripts/asc.rb | 37 +++ .../territory-pricing/scripts/check_setup.sh | 85 ++++++ skills/territory-pricing/scripts/estimate.rb | 124 ++++++++ skills/territory-pricing/scripts/lib/asc.rb | 171 +++++++++++ .../scripts/lib/data_sources.rb | 149 ++++++++++ .../territory-pricing/scripts/lib/pricing.rb | 199 +++++++++++++ .../territory-pricing/scripts/lib/products.rb | 275 ++++++++++++++++++ .../scripts/list_products.rb | 34 +++ .../territory-pricing/scripts/refresh_data.rb | 16 + skills/territory-pricing/scripts/snapshot.rb | 25 ++ skills/territory-pricing/tests/asc_test.rb | 56 ++++ .../tests/data_sources_test.rb | 40 +++ .../tests/fixtures/big_mac.csv | 7 + .../territory-pricing/tests/pricing_test.rb | 160 ++++++++++ .../territory-pricing/tests/products_test.rb | 105 +++++++ skills/territory-pricing/tests/run_all.rb | 4 + skills/territory-pricing/tests/test_helper.rb | 6 + 30 files changed, 2548 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 skills/territory-pricing/SKILL.md create mode 100644 skills/territory-pricing/references/asc-pricing-api.md create mode 100644 skills/territory-pricing/references/data/big-mac.csv create mode 100644 skills/territory-pricing/references/data/gni-bands.json create mode 100644 skills/territory-pricing/references/data/gni.csv create mode 100644 skills/territory-pricing/references/data/ppp.csv create mode 100644 skills/territory-pricing/references/setup.md create mode 100644 skills/territory-pricing/references/strategies.md create mode 100755 skills/territory-pricing/scripts/apply.rb create mode 100755 skills/territory-pricing/scripts/asc.rb create mode 100755 skills/territory-pricing/scripts/check_setup.sh create mode 100755 skills/territory-pricing/scripts/estimate.rb create mode 100644 skills/territory-pricing/scripts/lib/asc.rb create mode 100644 skills/territory-pricing/scripts/lib/data_sources.rb create mode 100644 skills/territory-pricing/scripts/lib/pricing.rb create mode 100644 skills/territory-pricing/scripts/lib/products.rb create mode 100755 skills/territory-pricing/scripts/list_products.rb create mode 100755 skills/territory-pricing/scripts/refresh_data.rb create mode 100755 skills/territory-pricing/scripts/snapshot.rb create mode 100644 skills/territory-pricing/tests/asc_test.rb create mode 100644 skills/territory-pricing/tests/data_sources_test.rb create mode 100644 skills/territory-pricing/tests/fixtures/big_mac.csv create mode 100644 skills/territory-pricing/tests/pricing_test.rb create mode 100644 skills/territory-pricing/tests/products_test.rb create mode 100644 skills/territory-pricing/tests/run_all.rb create mode 100644 skills/territory-pricing/tests/test_helper.rb 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..398e45c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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..1898c08 --- /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 }} + 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/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..e982d4b --- /dev/null +++ b/skills/territory-pricing/SKILL.md @@ -0,0 +1,160 @@ +--- +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. + +## Terms + +* **Territory**: an App Store storefront (USA, BRA, JPN, ...), about 175 of them. +* **Base territory**: the territory whose current price is the anchor, 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 (see `references/strategies.md`). +* **Price Proposal**: the table territory, current price, proposed price point. This is what the user approves. + +## Hard rules + +1. **Estimate before write.** `estimate.rb` only reads. `apply.rb` is a dry run unless `--confirm`. +2. **`--confirm` only after the user said yes** to the Price Proposal you showed in this conversation. An earlier "yes" for another proposal or product does not count. +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.** Report only what the read-back after the write confirmed. +6. Run scripts with absolute paths from this skill's directory; `SKILL_DIR` below is the folder holding this file. + +## 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 (details in `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 + +```bash +ruby "$SKILL_DIR/scripts/list_products.rb" --apps --bundle-id +ruby "$SKILL_DIR/scripts/list_products.rb" --app +``` + +If the working directory has an Xcode project, take the bundle id from it (`PRODUCT_BUNDLE_IDENTIFIER`); +otherwise list all apps. Ask which product to price: the app price, an in-app purchase, or a +subscription. For subscriptions, price **the whole subscription group** (every subscription in it) +with one start date, so a yearly plan never sits above a lifetime unlock for a day. +One kind and one group per run. + +### 3. Choose the strategy and settings + +Ask the user (AskUserQuestion). Read `references/strategies.md` for formulas and caveats first. + +| 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, a comparison | + +Also confirm the base territory (default: the schedule's base, else USA) and the guard rails: +clamp 30%..130% of the equalized price (`--clamp-min/--clamp-max`), flag changes above 25% +(`--flag-threshold`). Defaults are fine unless the user objects. + +### 4. Estimate (read-only) + +```bash +RUN="./territory-pricing//$(date +%Y-%m-%d-%H%M)" +ruby "$SKILL_DIR/scripts/estimate.rb" --kind --id \ + --strategy --base --out "$RUN" +``` + +It writes `before.json` (current prices, the rollback point), `proposal.json`, `proposal.csv` and +`price_points.json` into `$RUN`. For a subscription group run it once per subscription. + +Show the user the printed summary: counts, data sources with dates, largest decreases and increases, +skipped territories, plus the path to `proposal.csv`. Point out flags (`clamped`, `large-change`), +territories that used a fallback (`ratio_source` other than the chosen strategy), and any warning +about already scheduled price changes. Offer to add `territory-pricing/` to `.gitignore`. + +If the user edits `proposal.csv` (change `proposed`; empty it to leave a territory alone), rebuild: + +```bash +ruby "$SKILL_DIR/scripts/estimate.rb" --out "$RUN" --rebuild +``` + +Then show the new summary. To try another strategy, estimate again into a new `$RUN`. + +### 5. Get consent + +Ask plainly: apply this proposal? Name the product, strategy, number of territories that change and +the largest decrease. Wait for a clear yes. Anything else: adjust and re-estimate. + +### 6. Apply + +Dry run first and show it: + +```bash +ruby "$SKILL_DIR/scripts/apply.rb" --proposal "$RUN/proposal.json" +``` + +**App price and in-app purchase** (one schedule, all prices at once, effective immediately): + +```bash +ruby "$SKILL_DIR/scripts/apply.rb" --proposal "$RUN/proposal.json" --confirm +``` + +**Subscription** (one POST per territory; price changes that took effect cannot be undone): + +1. Warn explicitly and get a **second, separate yes**: new prices start on the start date, apply to + new subscribers, and cannot be reverted once live. By default existing subscribers keep their + price (`preserveCurrentPrice: true`); `--no-preserve` triggers Apple's price-increase consent + flow and needs the user to ask for it. +2. Canary on 1-2 territories, then check its read-back with the user: + ```bash + ruby "$SKILL_DIR/scripts/apply.rb" --proposal "$RUN/proposal.json" --confirm --phase canary --start-date + ``` +3. Only if the canary matches, apply the rest with the **same** `--start-date`: + ```bash + ruby "$SKILL_DIR/scripts/apply.rb" --proposal "$RUN/proposal.json" --confirm --phase rest --start-date + ``` + Do this for every subscription of the group with that date. `apply-log.json` makes a rerun resume + instead of repeating territories. + +### 7. Verify and report + +`apply.rb` reads the prices back (`after.json`, `verify.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 + +```bash +ruby "$SKILL_DIR/scripts/apply.rb" --rollback "$RUN/before.json" # dry run +ruby "$SKILL_DIR/scripts/apply.rb" --rollback "$RUN/before.json" --confirm +``` + +For subscriptions this first cancels still-scheduled changes. A change that already took effect cannot +be undone, only superseded by a new change, so say that before running it. + +## Gotchas that cost money + +* Posting a new base price alone wipes every per-territory price. The scripts send the base and all + manual prices together; never improvise a base-only call. +* New subscription prices start the next day at the earliest. +* Territories with no data for the chosen strategy fall back (Big Mac, then PPP, then equalization) + and the table says which one was used. +* Economic data is fetched live and falls back to the bundled snapshot; the summary prints each source and + its date. Refresh the snapshot 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`: endpoints per product kind and how the scripts use them 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..cbaa31f --- /dev/null +++ b/skills/territory-pricing/references/asc-pricing-api.md @@ -0,0 +1,46 @@ +# App Store Connect pricing endpoints + +What the scripts call, per product kind. Shapes follow Apple's App Store Connect API documentation. + +| | 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; chosen by the user (`--base`) | +| 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 | `GET /v3/appPricePoints/{pp}/equalizations` | `GET /v1/inAppPurchasePricePoints/{pp}/equalizations` | `GET /v1/subscriptionPricePoints/{pp}/equalizations` | +| Write | `POST /v1/appPriceSchedules` | `POST /v1/inAppPurchasePriceSchedules` | `POST /v1/subscriptionPrices`, one per territory | +| Territory availability (best effort) | `/v1/apps/{id}/appAvailabilityV2` -> `/v2/appAvailabilities/{id}/territoryAvailabilities` | `/v2/inAppPurchases/{id}/inAppPurchaseAvailability` -> `.../availableTerritories` | `/v1/subscriptions/{id}/subscriptionAvailability?include=availableTerritories` | + +Availability is read best effort: if Apple's answer cannot be read, every territory is treated as available +and a warning is printed. + +## Writes + +**App and IAP**: one POST with `baseTerritory` and a `manualPrices` entry (an `included` `appPrices` / +`inAppPurchasePrices` resource with a temporary `${priceN}` id) for every territory. A schedule posted without the +per-territory prices drops them back to Apple's automatic prices, so the scripts never post the base alone. +Territories left out of the proposal (unavailable, no price points) follow the automatic prices. + +**Subscription**: `POST /v1/subscriptionPrices` with `startDate` (default tomorrow) and +`preserveCurrentPrice` (default `true`: existing subscribers keep their price). There is no bulk endpoint. +Price changes that have taken effect cannot be reverted; scheduled ones can be cancelled +(`DELETE /v1/subscriptionPrices/{id}`), which is what `--rollback` does first. + +## Verification + +After a write `apply.rb` re-reads the product with the same calls as the snapshot and compares the price +point in effect (or, for subscriptions, a scheduled entry with the same start date) to the proposal. +Output: `after.json` and `verify.json` next to the proposal. + +## Rate limits and errors + +The client retries HTTP 429 and 5xx with backoff (honoring `Retry-After`), up to four attempts. An +estimate for one product reads about 175 price-point lists, one request per territory, so it takes a minute or two. +`401` means the key does not match its ids, `403` means the key role is too low. + +## Known unknowns + +The documentation does not show every response of `automaticPrices` and the IAP availability endpoints. If a +current price cannot be resolved, the table shows `n/a` for it and treats the territory as changed. The +scripts were checked against stubbed responses in this documented shape and against the live API only for +authentication; run a dry run on a real product before the first write. 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/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..8aa4ee2 --- /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` is GET-only. Writes exist only in `scripts/apply.rb`, behind `--confirm`. diff --git a/skills/territory-pricing/references/strategies.md b/skills/territory-pricing/references/strategies.md new file mode 100644 index 0000000..414654f --- /dev/null +++ b/skills/territory-pricing/references/strategies.md @@ -0,0 +1,87 @@ +# 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. + +## Options + +| Flag | Default | Meaning | +|---|---|---| +| `--base` | schedule base, 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 | +| `--offline` | off | use only the bundled data snapshots | + +## Data + +Fetched live on every estimate and cached nowhere; on failure the bundled snapshot in `references/data/` +is used and the summary shows `snapshot` with its date. Sources: The Economist big-mac-data (GitHub), +World Bank Indicators API. Refresh the snapshot 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/apply.rb b/skills/territory-pricing/scripts/apply.rb new file mode 100755 index 0000000..ba5e375 --- /dev/null +++ b/skills/territory-pricing/scripts/apply.rb @@ -0,0 +1,176 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Applies a Price Proposal to App Store Connect. DRY RUN unless --confirm. +# +# ruby apply.rb --proposal DIR/proposal.json # dry run +# ruby apply.rb --proposal DIR/proposal.json --confirm # app / iap +# ruby apply.rb --proposal DIR/proposal.json --confirm --phase canary # subscription: 1-2 territories +# ruby apply.rb --proposal DIR/proposal.json --confirm --phase rest # subscription: the rest +# ruby apply.rb --rollback DIR/before.json --confirm # back to the snapshot +# +# Every write is followed by a read-back from App Store Connect; only what the +# read-back confirms is reported as applied. +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'optparse' +require 'json' +require 'date' +require 'products' + +opts = { phase: nil, canary: 2, preserve: true, confirm: false } +OptionParser.new do |o| + o.on('--proposal FILE') { |v| opts[:proposal] = v } + o.on('--rollback FILE', 'before.json snapshot to restore') { |v| opts[:rollback] = v } + o.on('--confirm', 'actually write to App Store Connect') { opts[:confirm] = true } + o.on('--phase PHASE', %w[all canary rest]) { |v| opts[:phase] = v } + o.on('--canary N', Integer) { |v| opts[:canary] = v } + o.on('--start-date DATE', 'subscriptions: YYYY-MM-DD, default tomorrow') { |v| opts[:start] = v } + o.on('--no-preserve', 'subscriptions: do NOT keep current subscribers on their price') { opts[:preserve] = false } +end.parse! +abort 'usage: see header of apply.rb' unless opts[:proposal] || opts[:rollback] + +def die(msg) + abort "ERROR: #{msg}" +end + +def money(value, currency) + value ? format('%.2f %s', value, currency) : 'n/a' +end + +def load_source(opts) + unless opts[:rollback] + doc = JSON.parse(File.read(opts[:proposal])) + return [doc['meta'], doc['rows'], File.dirname(opts[:proposal])] + end + + snap = JSON.parse(File.read(opts[:rollback])) + rows = snap['prices'].select { |_, p| p['point_id'] }.map do |t, p| + { 'territory' => t, 'price_point_id' => p['point_id'], 'proposed' => p['price'], 'currency' => p['currency'], + 'current_point_id' => nil, 'current' => nil, 'change_pct' => nil } + end + [{ 'kind' => snap['kind'], 'id' => snap['id'], 'base_territory' => snap['base_territory'] || 'USA' }, rows, File.dirname(opts[:rollback])] +end + +def future_date?(str, today) + Date.parse(str) > Date.parse(today) +rescue ArgumentError + false +end + +def read_log(path) + File.file?(path) ? JSON.parse(File.read(path)) : { 'applied' => {}, 'failed' => {} } +end + +def verify(product, rows, run_dir, start_date) + after = product.snapshot + File.write(File.join(run_dir, 'after.json'), JSON.pretty_generate(after)) + bad = rows.reject do |r| + p = after['prices'][r['territory']] || {} + p['point_id'] == r['price_point_id'] || + (start_date && (p['scheduled'] || []).any? { |s| s['start_date'] == start_date && s['point_id'] == r['price_point_id'] }) + end + report = { 'checked' => rows.size, 'ok' => rows.size - bad.size, + 'mismatches' => bad.map { |r| { 'territory' => r['territory'], 'expected' => r['proposed'], 'point_id' => r['price_point_id'] } } } + File.write(File.join(run_dir, 'verify.json'), JSON.pretty_generate(report)) + report +end + +begin + meta, rows, run_dir = load_source(opts) + kind = meta['kind'] + product = Products.build(kind, meta['id']) + base = meta['base_territory'] + subscription = kind == 'subscription' + opts[:phase] ||= subscription ? 'canary' : 'all' + die 'subscriptions must go through --phase canary, then --phase rest' if subscription && opts[:phase] == 'all' + die "--phase #{opts[:phase]} is for subscriptions only" if !subscription && opts[:phase] != 'all' + + today = Products.today + start = opts[:start] || (Date.parse(today) + 1).to_s + die "--start-date must be a date after #{today}" if subscription && !future_date?(start, today) + + cancel = [] + if opts[:rollback] + live = product.snapshot + # only cancel changes that were not already scheduled when the snapshot was taken + known = JSON.parse(File.read(opts[:rollback]))['prices'].values.flat_map { |p| (p['scheduled'] || []).map { |s| s['price_id'] } } + live['prices'].each do |t, p| + (p['scheduled'] || []).reject { |s| known.include?(s['price_id']) }.each { |s| cancel << [t, s] } if subscription + rows.each { |r| r['current_point_id'] = p['point_id'] if r['territory'] == t } + end + end + + todo = rows.select { |r| Pricing.changed?(r) } + submit = rows.select { |r| r['price_point_id'] } + puts "#{opts[:confirm] ? 'APPLY' : 'DRY RUN'}: #{kind} #{meta['id']} (#{opts[:rollback] ? 'rollback' : meta['strategy']}), base #{base}" + puts " #{todo.size} of #{rows.size} territories differ from the current price" + puts " will cancel #{cancel.size} scheduled subscription price change(s) first" unless cancel.empty? + if todo.empty? && cancel.empty? + puts 'Nothing to do.' + exit 0 + end + + if subscription + log_path = File.join(run_dir, 'apply-log.json') + log = read_log(log_path) + pending = todo.reject { |r| log['applied'].key?(r['territory']) } + canary = pending.reject { |r| r['territory'] == base }.first(opts[:canary]) + canary = pending.first(opts[:canary]) if canary.empty? + if opts[:phase] == 'canary' + batch = canary + else + die 'run --phase canary first and check its read-back' if log['applied'].empty? + batch = pending + end + puts " phase #{opts[:phase]}: #{batch.size} POST /v1/subscriptionPrices, startDate #{start}, preserveCurrentPrice #{opts[:preserve]}" + batch.first(opts[:confirm] ? 0 : 8).each do |r| + puts format(' %-4s %-14s -> %-14s', r['territory'], money(r['current'], r['currency']), money(r['proposed'], r['currency'])) + end + puts ' ...' if !opts[:confirm] && batch.size > 8 + unless opts[:confirm] + puts 'Dry run only. Nothing was written. Re-run with --confirm after the user agrees.' + exit 0 + end + + cancel.each { |_, s| product.cancel_scheduled!(s['price_id']) } + puts " cancelled #{cancel.size} scheduled price change(s)" unless cancel.empty? + if batch.empty? + puts 'No price writes needed: the prices in effect already match.' + exit 0 + end + batch.each do |r| + product.apply_one!(r, start, opts[:preserve]) + log['applied'][r['territory']] = { 'point_id' => r['price_point_id'], 'start_date' => start, 'at' => Time.now.utc.iso8601 } + log['failed'].delete(r['territory']) + puts " wrote #{r['territory']}" + rescue Asc::HttpError => e + log['failed'][r['territory']] = e.message + warn " FAILED #{r['territory']}: #{e.message}" + break if opts[:phase] == 'canary' + ensure + File.write(log_path, JSON.pretty_generate(log)) + end + # the final report covers everything applied so far, canary included + checked = (opts[:phase] == 'rest' ? todo : batch).select { |r| log['applied'].key?(r['territory']) } + else + puts " 1 POST creating a new price schedule: base #{base}, #{submit.size} manual prices (territories left out follow Apple's automatic prices)" + unless opts[:confirm] + puts 'Dry run only. Nothing was written. Re-run with --confirm after the user agrees.' + exit 0 + end + product.apply_all!(submit, base) + puts ' schedule created' + checked = submit + end + + report = verify(product, checked, run_dir, subscription ? start : nil) + puts "Read-back: #{report['ok']}/#{report['checked']} territories match what was sent" + report['mismatches'].first(20).each { |m| puts " MISMATCH #{m['territory']}: expected #{m['expected']}" } + failed = subscription ? log['failed'] : {} + failed.each { |t, msg| puts " FAILED #{t}: #{msg}" } + puts 'Subscriptions: new prices start on the start date; App Store Connect keeps showing the old price until then (scheduled change, not a failure).' if subscription + puts 'Next: run --phase rest after the user reviews this read-back.' if subscription && opts[:phase] == 'canary' + exit(report['mismatches'].empty? && failed.empty? ? 0 : 1) +rescue Asc::Error => e + abort "ERROR: #{e.message}" +end diff --git a/skills/territory-pricing/scripts/asc.rb b/skills/territory-pricing/scripts/asc.rb new file mode 100755 index 0000000..047aaed --- /dev/null +++ b/skills/territory-pricing/scripts/asc.rb @@ -0,0 +1,37 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Read-only App Store Connect client for diagnostics. +# ruby asc.rb --credentials resolve and validate the key (no key material printed) +# ruby asc.rb GET '/v1/apps?limit=1' +# GET only: everything that writes lives in apply.rb behind --confirm. +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'asc' + +begin + if ARGV[0] == '--credentials' + creds = Asc.credentials + Asc.private_key(creds.p8_path) + puts "key_id=#{creds.key_id}" + puts "issuer_id=#{creds.issuer_id}" + puts "p8_path=#{creds.p8_path}" + puts "config_path=#{Asc::CONFIG_PATH}" + exit 0 + end + + method, target = ARGV + abort 'usage: ruby asc.rb --credentials | GET ' unless method&.upcase == 'GET' && target + + begin + body = Asc.request(:get, target) + puts 'HTTP 200' + puts JSON.pretty_generate(body) if body + rescue Asc::HttpError => e + puts "HTTP #{e.code}" + puts e.message + exit 1 + end +rescue Asc::Error => e + warn "ERROR: #{e.message}" + exit 2 +end 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/estimate.rb b/skills/territory-pricing/scripts/estimate.rb new file mode 100755 index 0000000..4d6a288 --- /dev/null +++ b/skills/territory-pricing/scripts/estimate.rb @@ -0,0 +1,124 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Builds a Price Proposal. Read-only: it never writes to App Store Connect. +# ruby estimate.rb --kind subscription --id 6447497832 --strategy gni_bands --out DIR +# ruby estimate.rb --out DIR --rebuild # re-read proposal.csv after hand edits +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'optparse' +require 'fileutils' +require 'json' +require 'products' +require 'data_sources' + +opts = { strategy: 'gni_bands' } +overrides = {} +OptionParser.new do |o| + o.on('--kind KIND', Products::KINDS) { |v| opts[:kind] = v } + o.on('--id ID') { |v| opts[:id] = v } + o.on('--strategy S', Pricing::STRATEGIES) { |v| opts[:strategy] = v } + o.on('--base TERRITORY', 'base territory, default: the schedule base or USA') { |v| overrides[:base_territory] = v.upcase } + o.on('--out DIR') { |v| opts[:out] = v } + 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('--offline', 'use bundled data snapshots only') { opts[:offline] = true } + o.on('--rebuild', 'rebuild proposal.json from an edited proposal.csv') { opts[:rebuild] = true } +end.parse! +abort 'usage: see header of estimate.rb' unless opts[:out] + +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['kind']} #{meta['id']} | 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(', ')}" + moved = rows.select { |r| r['change_pct'] && Pricing.changed?(r) }.sort_by { |r| r['change_pct'] } + { 'Largest decreases' => moved.first(8), '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| r['proposed'].nil? } + puts "Skipped: #{skipped.map { |r| "#{r['territory']} (#{r['note']})" }.join(', ')}" unless skipped.empty? +end + +begin + FileUtils.mkdir_p(opts[:out]) + proposal_path = File.join(opts[:out], 'proposal.json') + points_path = File.join(opts[:out], 'price_points.json') + + if opts[:rebuild] + proposal = JSON.parse(File.read(proposal_path)) + popts = Pricing.options(overrides.merge(base_territory: proposal['meta']['base_territory'])) + proposal['rows'] = Pricing.apply_csv_edits(proposal['rows'], File.read(File.join(opts[:out], 'proposal.csv')), + JSON.parse(File.read(points_path)), popts) + File.write(proposal_path, JSON.pretty_generate(proposal)) + File.write(File.join(opts[:out], 'proposal.csv'), Pricing.to_csv(proposal['rows'])) + print_summary(proposal['meta'], proposal['rows']) + exit 0 + end + + abort 'usage: see header of estimate.rb' unless opts[:kind] && opts[:id] + product = Products.build(opts[:kind], opts[:id]) + + warn 'reading current prices...' + snap = product.snapshot + File.write(File.join(opts[:out], 'before.json'), JSON.pretty_generate(snap)) + base = overrides[:base_territory] || snap['base_territory'] || 'USA' + popts = Pricing.options(overrides.merge(base_territory: base)) + base_entry = snap['prices'][base] + abort "ERROR: no price in effect in base territory #{base}; pick another with --base" unless base_entry && base_entry['point_id'] + + warn "reading equalized prices from #{base}..." + equalized = product.equalized(base_entry['point_id']) + equalized[base] ||= { 'price' => base_entry['price'], 'currency' => base_entry['currency'] } + availability = product.available_territories + + territories = equalized.keys.sort + points = {} + territories.each_with_index do |t, i| + warn format('reading price points [%d/%d] %s', i: i + 1, n: territories.size, t: t) + points[t] = product.price_points(t) + end + File.write(points_path, JSON.pretty_generate(points)) + + data, provenance = DataSources.load(DataSources.needed_for(opts[:strategy]), offline: opts[:offline]) + 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 + tier_ratios = (cfg['tier_ratios'] || {}).map { |k, v| [Integer(k), Float(v)] }.to_h + popts = popts.merge(tier_ratios: tier_ratios) unless tier_ratios.empty? + popts = popts.merge(default_tier: Integer(cfg['default_tier'])) if cfg['default_tier'] + end + missing = DataSources.needed_for(opts[:strategy]).select { |n| data[n].nil? } + abort "ERROR: no #{missing.join(', ')} data (live fetch failed and no bundled snapshot)" unless missing.empty? + + inputs = territories.map do |t| + cur = snap['prices'][t] || {} + { 'territory' => t, 'currency' => equalized[t]['currency'] || cur['currency'], 'current' => cur['price'], + 'current_point_id' => cur['point_id'], 'equalized' => equalized[t]['price'], + 'available' => availability.nil? || availability.include?(t), 'points' => points[t] } + end + rows = Pricing.build_proposal(inputs, opts[:strategy], data, popts) + + meta = { 'kind' => opts[:kind], 'id' => opts[:id], 'strategy' => opts[:strategy], 'base_territory' => base, + 'created_at' => Time.now.utc.iso8601, 'data' => provenance, + 'options' => popts.reject { |k, _| %i[band_ratios tier_ratios].include?(k) } } + File.write(proposal_path, JSON.pretty_generate('meta' => meta, 'rows' => rows)) + File.write(File.join(opts[:out], 'proposal.csv'), Pricing.to_csv(rows)) + print_summary(meta, rows) + scheduled = snap['prices'].count { |_, p| !p['scheduled'].empty? } + puts "WARNING: #{scheduled} territories already have a scheduled price change." if scheduled.positive? + puts "Files: #{opts[:out]}/{before.json,proposal.json,proposal.csv,price_points.json}" +rescue Asc::Error => e + abort "ERROR: #{e.message}" +end diff --git a/skills/territory-pricing/scripts/lib/asc.rb b/skills/territory-pricing/scripts/lib/asc.rb new file mode 100644 index 0000000..aa9e9ef --- /dev/null +++ b/skills/territory-pricing/scripts/lib/asc.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +# Minimal App Store Connect API client: ES256 JWT, Ruby stdlib only. +# Never prints key material. +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) + + module_function + + # Env vars (ASC_KEY_ID, ASC_ISSUER_ID, ASC_P8) win over the key folder. + def credentials + @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 + now = Time.now.to_i + if @token.nil? || now > @token_at + 900 + @token = jwt(credentials, now) + @token_at = now + end + @token + 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}?#{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 res.body.to_s.empty? ? nil : JSON.parse(res.body) + end + 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 +end diff --git a/skills/territory-pricing/scripts/lib/data_sources.rb b/skills/territory-pricing/scripts/lib/data_sources.rb new file mode 100644 index 0000000..3557d83 --- /dev/null +++ b/skills/territory-pricing/scripts/lib/data_sources.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'csv' +require 'json' +require 'net/http' +require 'uri' +require 'time' + +# Economic inputs for the pricing strategies. Live fetch first, bundled +# snapshot (references/data/) when the network or the source fails. +module DataSources + DATA_DIR = File.expand_path('../../references/data', __dir__) + 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 + + # -> [{ big_mac:, ppp:, gni:, band1_overrides: }, provenance] + def load(needed, offline: false) + data = { band1_overrides: band1_overrides } + provenance = {} + needed.each do |name| + values, meta = offline ? [nil, nil] : try_live(name) + values, meta = snapshot(name) unless values + data[name] = values + provenance[name.to_s] = meta + end + [data, provenance] + end + + def needed_for(strategy) + { 'big_mac' => %i[big_mac ppp], 'ppp' => %i[ppp], 'gni_bands' => %i[gni] }.fetch(strategy, []) + end + + def try_live(name) + send("fetch_#{name}") + rescue StandardError => e + warn "warn: live #{name} fetch failed (#{e.class}: #{e.message}); using bundled snapshot" + nil + end + + 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 = JSON.parse(get("#{WORLD_BANK}/country?format=json&per_page=400"))[1] + .reject { |c| c['region']['id'] == 'NA' }.map { |c| c['id'] } + 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 + + # ---- bundled snapshot --------------------------------------------------- + + def snapshot(name) + file = { big_mac: 'big-mac.csv', ppp: 'ppp.csv', gni: 'gni.csv' }.fetch(name) + path = File.join(DATA_DIR, file) + return [nil, { 'origin' => 'missing', 'as_of' => nil }] unless File.file?(path) + + rows = CSV.read(path, headers: true) + values = rows.each_with_object({}) { |r, h| h[r['iso_a3']] = Float(r['value']) } + [values, { 'origin' => 'snapshot', 'as_of' => rows.map { |r| r['as_of'] }.max, 'source' => "references/data/#{file}" }] + end + + def band1_overrides + path = File.join(DATA_DIR, 'gni-bands.json') + File.file?(path) ? JSON.parse(File.read(path))['band1_overrides'] : [] + end + + def write_snapshot(name, values, as_of) + file = { big_mac: 'big-mac.csv', ppp: 'ppp.csv', gni: 'gni.csv' }.fetch(name) + File.write(File.join(DATA_DIR, file), 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 diff --git a/skills/territory-pricing/scripts/lib/pricing.rb b/skills/territory-pricing/scripts/lib/pricing.rb new file mode 100644 index 0000000..eead1a4 --- /dev/null +++ b/skills/territory-pricing/scripts/lib/pricing.rb @@ -0,0 +1,199 @@ +# 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 + }.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 + + # -> [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. + def nearest_point(target, points) + points.min_by { |p| [(p['price'] - target).abs, p['price']] } + end + + # entries: {'territory','point_id','price','start_date','end_date',...} + # -> [price in effect today or nil, scheduled future changes] + def split_entries(entries, today) + live = entries.select do |e| + (e['start_date'].nil? || e['start_date'] <= today) && (e['end_date'].nil? || e['end_date'] > today) + end + scheduled = entries.select { |e| e['start_date'] && e['start_date'] > today } + [live.max_by { |e| e['start_date'] || '' }, scheduled.sort_by { |e| e['start_date'] }] + 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? + + ratio, source = if input['territory'] == opts[:base_territory] + [1.0, 'base'] + else + ratio_for(strategy, input['territory'], data, opts) + end + applied = clamp_ratio(ratio, opts) + target = input['equalized'] * applied + point = nearest_point(target, input['points']) + row.merge!('ratio_source' => source, 'ratio' => applied.round(4), 'clamped' => applied != ratio, + 'target' => target.round(2), 'price_point_id' => point['id'], 'proposed' => point['price']) + finish(row, opts) + end + + def skip(row, note) + row.merge('note' => note) + 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.select { |r| r['proposed'] } + 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| r['proposed'].nil? }, + '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 + + # Re-snaps hand-edited `proposed` values of a proposal.csv onto real price + # points. An emptied `proposed` cell means "leave this territory alone". + # points_by_territory: {'BRA' => [{'id','price'}, ...]} + def apply_csv_edits(rows, csv_text, points_by_territory, opts) + edited = CSV.parse(csv_text, headers: true).each_with_object({}) { |r, h| h[r['territory']] = r['proposed'] } + rows.map do |row| + next row unless edited.key?(row['territory']) + + raw = edited[row['territory']].to_s.strip + if raw.empty? + next row['proposed'].nil? ? row : row.merge('price_point_id' => row['current_point_id'], + 'proposed' => row['current'], 'flag' => '', 'note' => 'left unchanged by user') + end + value = Float(raw) + next row if row['proposed'] && (value - row['proposed']).abs < 0.005 + + 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', + 'note' => 'edited by user'), opts) + end + end +end diff --git a/skills/territory-pricing/scripts/lib/products.rb b/skills/territory-pricing/scripts/lib/products.rb new file mode 100644 index 0000000..91fb1c0 --- /dev/null +++ b/skills/territory-pricing/scripts/lib/products.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require 'time' +require 'asc' +require 'pricing' + +# One adapter per product kind. Each answers the same questions (current +# prices, price points, equalized prices) and knows how to write prices back. +module Products + KINDS = %w[app iap subscription].freeze + + module_function + + def build(kind, id) + { 'app' => App, 'iap' => Iap, 'subscription' => Subscription }.fetch(kind) { raise Asc::Error, "unknown kind #{kind}" }.new(id) + end + + def today + Time.now.utc.strftime('%Y-%m-%d') + end + + # Products the user can price for one app: the app itself, its IAPs and + # every auto-renewable subscription, grouped by subscription group. + def list(app_id) + items = [{ 'kind' => 'app', 'id' => app_id, 'name' => 'App price (paid app)' }] + Asc.paginate(Asc.path("/v1/apps/#{app_id}/inAppPurchasesV2", 'limit' => 200))['data'].each do |d| + items << { 'kind' => 'iap', 'id' => d['id'], 'name' => d.dig('attributes', 'name'), + 'detail' => d.dig('attributes', 'inAppPurchaseType') } + end + Asc.paginate(Asc.path("/v1/apps/#{app_id}/subscriptionGroups", 'limit' => 200))['data'].each do |g| + Asc.paginate(Asc.path("/v1/subscriptionGroups/#{g['id']}/subscriptions", 'limit' => 200))['data'].each do |s| + items << { 'kind' => 'subscription', 'id' => s['id'], 'name' => s.dig('attributes', 'name'), + 'group' => g.dig('attributes', 'referenceName'), 'detail' => s.dig('attributes', 'subscriptionPeriod') } + end + end + items + end + + class Base + attr_reader :id + + def initialize(id) + @id = id + end + + # {'territory' => {'point_id','price','currency','start_date','scheduled' => [...]}} + def snapshot(today = Products.today) + prices = current_entries.group_by { |e| e['territory'] }.each_with_object({}) do |(territory, entries), out| + current, scheduled = Pricing.split_entries(entries, today) + out[territory] = (current || {}).reject { |k, _| k == 'end_date' }.merge('scheduled' => scheduled) + end + { 'kind' => kind, 'id' => id, 'fetched_at' => Time.now.utc.iso8601, 'base_territory' => base_territory, 'prices' => prices } + end + + # All price points of one territory: [{'id','price'}] + def price_points(territory) + points_from(Asc.paginate(Asc.path(price_points_path, 'filter[territory]' => territory, 'limit' => 200))) + end + + # Equalized price points of a base point, one per territory: + # {'BRA' => {'price' => 59.9, 'currency' => 'BRL'}} + def equalized(point_id) + resp = Asc.paginate(Asc.path(equalizations_path(point_id), 'include' => 'territory', 'limit' => 200)) + inc = Asc.index_included(resp['included']) + resp['data'].each_with_object({}) do |d, h| + territory = territory_of(d) + price = price_of(d) + next unless territory && price + + h[territory] = { 'price' => price, 'currency' => inc[['territories', territory]]&.dig('attributes', 'currency') } + end + end + + # Territories the product is for sale in, or nil when Apple's answer + # could not be read (callers then treat every territory as available). + def available_territories + fetch_available + rescue Asc::HttpError => e + warn "warn: could not read territory availability (#{e.message}); treating all territories as available" + nil + end + + private + + def price_of(resource) + v = resource.dig('attributes', 'customerPrice') || resource.dig('attributes', 'equalizationPrice') + v && Float(v, exception: false) + end + + def territory_of(resource) + resource.dig('relationships', 'territory', 'data', 'id') + end + + def points_from(resp) + resp['data'].map { |d| { 'id' => d['id'], 'price' => price_of(d) } }.select { |p| p['price'] } + end + + # price resources (appPrices / inAppPurchasePrices / subscriptionPrices) + # joined with their price point and territory + def entries_from(resp, point_rel) + inc = Asc.index_included(resp['included']) + resp['data'].map do |p| + ref = p.dig('relationships', point_rel, 'data') + point = ref && inc[[ref['type'], ref['id']]] + territory = territory_of(p) || (point && territory_of(point)) + { + 'territory' => territory, 'point_id' => ref && ref['id'], 'price' => point && price_of(point), + 'currency' => inc[['territories', territory]]&.dig('attributes', 'currency'), + 'start_date' => p.dig('attributes', 'startDate'), 'end_date' => p.dig('attributes', 'endDate'), + 'price_id' => p['id'] + } + end.select { |e| e['territory'] } + end + + def reference(type, id) + { 'data' => { 'type' => type, 'id' => id } } + end + end + + class App < Base + def kind + 'app' + end + + def schedule_id + @schedule_id ||= Asc.request(:get, "/v1/apps/#{id}/appPriceSchedule")['data']['id'] + end + + def base_territory + Asc.request(:get, "/v1/appPriceSchedules/#{schedule_id}/baseTerritory")['data']['id'] + end + + def current_entries + %w[manualPrices automaticPrices].flat_map do |rel| + resp = Asc.paginate(Asc.path("/v1/appPriceSchedules/#{schedule_id}/#{rel}", + 'include' => 'appPricePoint,territory', 'limit' => 200)) + entries_from(resp, 'appPricePoint') + end + end + + # One schedule with the base territory and every manual price. Posting + # the base alone would wipe the per-territory prices, so never do that. + def apply_all!(rows, base) + body = schedule_body('appPriceSchedules', 'app', 'apps', 'appPrices', 'appPricePoint', 'appPricePoints', rows, base) + Asc.request(:post, '/v1/appPriceSchedules', body) + end + + private + + def price_points_path + + "/v1/apps/#{id}/appPricePoints" + + end + + def equalizations_path(point_id) + + "/v3/appPricePoints/#{point_id}/equalizations" + + end + + def fetch_available + av = Asc.request(:get, "/v1/apps/#{id}/appAvailabilityV2")['data']['id'] + resp = Asc.paginate(Asc.path("/v2/appAvailabilities/#{av}/territoryAvailabilities", 'limit' => 200)) + resp['data'].select { |d| d.dig('attributes', 'available') }.map { |d| territory_of(d) }.compact + end + + def schedule_body(type, rel, rel_type, price_type, point_rel, point_type, rows, base) + included = rows.each_with_index.map do |row, i| + { 'type' => price_type, 'id' => "${price#{i}}", 'attributes' => { 'startDate' => nil }, + 'relationships' => { point_rel => reference(point_type, row['price_point_id']) } } + end + { 'data' => { 'type' => type, 'attributes' => {}, + 'relationships' => { rel => reference(rel_type, id), 'baseTerritory' => reference('territories', base), + 'manualPrices' => { 'data' => included.map { |i| { 'type' => price_type, 'id' => i['id'] } } } } }, + 'included' => included } + end + end + + class Iap < App + def kind + 'iap' + end + + def schedule_id + @schedule_id ||= Asc.request(:get, "/v2/inAppPurchases/#{id}/iapPriceSchedule")['data']['id'] + end + + def base_territory + Asc.request(:get, "/v1/inAppPurchasePriceSchedules/#{schedule_id}/baseTerritory")['data']['id'] + end + + def current_entries + %w[manualPrices automaticPrices].flat_map do |rel| + resp = Asc.paginate(Asc.path("/v1/inAppPurchasePriceSchedules/#{schedule_id}/#{rel}", + 'include' => 'inAppPurchasePricePoint,territory', 'limit' => 200)) + entries_from(resp, 'inAppPurchasePricePoint') + end + end + + def apply_all!(rows, base) + body = schedule_body('inAppPurchasePriceSchedules', 'inAppPurchase', 'inAppPurchases', 'inAppPurchasePrices', + 'inAppPurchasePricePoint', 'inAppPurchasePricePoints', rows, base) + Asc.request(:post, '/v1/inAppPurchasePriceSchedules', body) + end + + private + + def price_points_path + + "/v2/inAppPurchases/#{id}/pricePoints" + + end + + def equalizations_path(point_id) + + "/v1/inAppPurchasePricePoints/#{point_id}/equalizations" + + end + + def fetch_available + av = Asc.request(:get, "/v2/inAppPurchases/#{id}/inAppPurchaseAvailability")['data']['id'] + resp = Asc.paginate(Asc.path("/v1/inAppPurchaseAvailabilities/#{av}/availableTerritories", 'limit' => 200)) + resp['data'].map { |d| d['id'] } + end + end + + class Subscription < Base + def kind + 'subscription' + end + + # Subscriptions have no base territory in the API; the caller picks one. + def base_territory + nil + end + + def current_entries + resp = Asc.paginate(Asc.path("/v1/subscriptions/#{id}/prices", 'include' => 'subscriptionPricePoint,territory', 'limit' => 200)) + entries_from(resp, 'subscriptionPricePoint') + end + + # One call per territory; Apple has no bulk endpoint. + def apply_one!(row, start_date, preserve) + body = { 'data' => { 'type' => 'subscriptionPrices', + 'attributes' => { 'startDate' => start_date, 'preserveCurrentPrice' => preserve }, + 'relationships' => { 'subscription' => reference('subscriptions', id), + 'subscriptionPricePoint' => reference('subscriptionPricePoints', row['price_point_id']) } } } + Asc.request(:post, '/v1/subscriptionPrices', body) + end + + def cancel_scheduled!(price_id) + Asc.request(:delete, "/v1/subscriptionPrices/#{price_id}") + end + + private + + def price_points_path + + "/v1/subscriptions/#{id}/pricePoints" + + end + + def equalizations_path(point_id) + + "/v1/subscriptionPricePoints/#{point_id}/equalizations" + + end + + def fetch_available + av = Asc.request(:get, "/v1/subscriptions/#{id}/subscriptionAvailability?include=availableTerritories") + (av.dig('data', 'relationships', 'availableTerritories', 'data') || []).map { |t| t['id'] } + end + end +end diff --git a/skills/territory-pricing/scripts/list_products.rb b/skills/territory-pricing/scripts/list_products.rb new file mode 100755 index 0000000..62cd734 --- /dev/null +++ b/skills/territory-pricing/scripts/list_products.rb @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Read-only lookup of apps and their priceable products. +# ruby list_products.rb --apps [--bundle-id com.acme.app] +# ruby list_products.rb --app 6447402192 +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'optparse' +require 'json' +require 'products' + +opts = {} +OptionParser.new do |o| + o.on('--apps') { opts[:apps] = true } + o.on('--bundle-id ID') { |v| opts[:bundle_id] = v } + o.on('--app ID') { |v| opts[:app] = v } +end.parse! +abort 'usage: ruby list_products.rb --apps [--bundle-id ID] | --app APP_ID' unless opts[:apps] || opts[:app] + +begin + if opts[:apps] + params = { 'limit' => 200, 'fields[apps]' => 'name,bundleId' } + params['filter[bundleId]'] = opts[:bundle_id] if opts[:bundle_id] + Asc.paginate(Asc.path('/v1/apps', params))['data'].each do |a| + puts format('%-12s %-40s %s', a['id'], a.dig('attributes', 'bundleId'), a.dig('attributes', 'name')) + end + else + Products.list(opts[:app]).each do |p| + puts format('%-13s %-12s %-28s %s', p['kind'], p['id'], p['name'], [p['group'], p['detail']].compact.join(' / ')) + end + end +rescue Asc::Error => e + abort "ERROR: #{e.message}" +end diff --git a/skills/territory-pricing/scripts/refresh_data.rb b/skills/territory-pricing/scripts/refresh_data.rb new file mode 100755 index 0000000..2da4e89 --- /dev/null +++ b/skills/territory-pricing/scripts/refresh_data.rb @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Refreshes the bundled snapshots in references/data/ from the live sources. +# Usage: ruby refresh_data.rb +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'data_sources' + +%i[big_mac ppp gni].each do |name| + values, meta = DataSources.send("fetch_#{name}") + DataSources.write_snapshot(name, values, meta['as_of']) + puts "#{name}: #{values.size} countries, as of #{meta['as_of']}" +rescue StandardError => e + warn "#{name}: FAILED (#{e.class}: #{e.message}); snapshot left unchanged" + exit 1 +end diff --git a/skills/territory-pricing/scripts/snapshot.rb b/skills/territory-pricing/scripts/snapshot.rb new file mode 100755 index 0000000..d5173c2 --- /dev/null +++ b/skills/territory-pricing/scripts/snapshot.rb @@ -0,0 +1,25 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Writes the current per-territory prices of one product to a JSON file. +# ruby snapshot.rb --kind subscription --id 6447497832 --out before.json +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'optparse' +require 'json' +require 'products' + +opts = {} +OptionParser.new do |o| + o.on('--kind KIND', Products::KINDS) { |v| opts[:kind] = v } + o.on('--id ID') { |v| opts[:id] = v } + o.on('--out FILE') { |v| opts[:out] = v } +end.parse! +abort 'usage: ruby snapshot.rb --kind app|iap|subscription --id ID --out FILE' unless opts.values_at(:kind, :id, :out).all? + +begin + snap = Products.build(opts[:kind], opts[:id]).snapshot + File.write(opts[:out], JSON.pretty_generate(snap)) + puts "#{snap['prices'].size} territories written to #{opts[:out]}" +rescue Asc::Error => e + abort "ERROR: #{e.message}" +end diff --git a/skills/territory-pricing/tests/asc_test.rb b/skills/territory-pricing/tests/asc_test.rb new file mode 100644 index 0000000..a554290 --- /dev/null +++ b/skills/territory-pricing/tests/asc_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require 'asc' +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.new('prime256v1').generate_key + 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 + public_key = OpenSSL::PKey::EC.new(key.public_key.group) + public_key.public_key = key.public_key + assert public_key.verify(OpenSSL::Digest::SHA256.new, der, "#{header}.#{payload}") + end + end + + def test_rejects_keys_that_are_not_p256 + with_key(OpenSSL::PKey::EC.new('secp384r1').generate_key.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_token_is_never_sent_to_another_host + assert_raises(Asc::Error) { Asc.request(:get, 'https://example.com/v1/apps') } + end +end diff --git a/skills/territory-pricing/tests/data_sources_test.rb b/skills/territory-pricing/tests/data_sources_test.rb new file mode 100644 index 0000000..a865c6f --- /dev/null +++ b/skills/territory-pricing/tests/data_sources_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require 'data_sources' + +class DataSourcesTest < Minitest::Test + def parsed + DataSources.parse_big_mac(File.read(File.join(__dir__, 'fixtures', 'big_mac.csv')), 2026) + end + + def test_big_mac_keeps_latest_fresh_rows_only + values, as_of = parsed + assert_equal '2026-07-01', as_of + assert_in_delta 6.22, values['USA'] + refute values.key?('RUS'), 'stale rows are dropped' + refute values.key?('BAD'), 'rows without a price are dropped' + end + + def test_euro_area_row_is_spread_over_members + values, = parsed + assert_in_delta 7.07, values['DEU'] + assert_in_delta 7.07, values['FRA'] + refute values.key?('EUZ') + end + + def test_needed_data_per_strategy + assert_equal %i[big_mac ppp], DataSources.needed_for('big_mac') + assert_equal %i[gni], DataSources.needed_for('gni_bands') + assert_equal [], DataSources.needed_for('equalization') + end + + def test_bundled_snapshots_load_offline + data, provenance = DataSources.load(%i[big_mac ppp gni], offline: true) + %i[big_mac ppp gni].each do |name| + assert data[name].size > 50, "#{name} snapshot is too small" + assert_equal 'snapshot', provenance[name.to_s]['origin'] + end + assert_includes data[:band1_overrides], 'JPN' + end +end diff --git a/skills/territory-pricing/tests/fixtures/big_mac.csv b/skills/territory-pricing/tests/fixtures/big_mac.csv new file mode 100644 index 0000000..7aad93e --- /dev/null +++ b/skills/territory-pricing/tests/fixtures/big_mac.csv @@ -0,0 +1,7 @@ +date,iso_a3,currency_code,name,local_price,dollar_ex,dollar_price +2026-07-01,USA,USD,United States,6.22,1,6.22 +2026-07-01,BRA,BRL,Brazil,26.3,5.59,4.705 +2026-07-01,EUZ,EUR,Euro area,6.0,0.85,7.07 +2022-01-01,RUS,RUB,Russia,135,75,1.8 +2026-07-01,JPN,JPY,Japan,480,155,3.08 +2025-01-01,BAD,XXX,Broken,1,1, diff --git a/skills/territory-pricing/tests/pricing_test.rb b/skills/territory-pricing/tests/pricing_test.rb new file mode 100644 index 0000000..bf5d752 --- /dev/null +++ b/skills/territory-pricing/tests/pricing_test.rb @@ -0,0 +1,160 @@ +# 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 + row = Pricing.build_row(input('BRA', 10.0, points(5), 'available' => false), 'gni_bands', DATA, OPTS) + assert_nil row['proposed'] + assert_equal 'unavailable', row['note'] + 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_split_entries_separates_current_from_scheduled + entries = [ + { 'start_date' => nil, 'end_date' => '2026-01-01', 'point_id' => 'a' }, + { 'start_date' => nil, 'end_date' => nil, 'point_id' => 'b' }, + { 'start_date' => '2026-05-01', 'end_date' => nil, 'point_id' => 'c' }, + { 'start_date' => '2099-01-01', 'end_date' => nil, 'point_id' => 'd' } + ] + current, scheduled = Pricing.split_entries(entries, '2026-10-30') + assert_equal 'c', current['point_id'] + assert_equal ['d'], scheduled.map { |e| e['point_id'] } + 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 +end diff --git a/skills/territory-pricing/tests/products_test.rb b/skills/territory-pricing/tests/products_test.rb new file mode 100644 index 0000000..c68e31b --- /dev/null +++ b/skills/territory-pricing/tests/products_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require 'products' + +# Canned App Store Connect responses (shapes follow Apple's documentation); +# Asc.paginate / Asc.request are stubbed, so nothing touches the network. +class ProductsTest < Minitest::Test + def setup + @original = { request: Asc.method(:request), paginate: Asc.method(:paginate) } + @calls = [] + calls = @calls + @responses = {} + responses = @responses + stub = lambda do |_name, target, body = nil| + calls << [target, body] + key = responses.keys.find { |k| target.include?(k) } or raise "unstubbed request: #{target}" + responses[key] + end + Asc.singleton_class.send(:define_method, :request) { |method, target, body = nil| stub.call(method, target, body) } + Asc.singleton_class.send(:define_method, :paginate) { |target| stub.call(:get, target) } + end + + def teardown + @original.each { |name, method| Asc.singleton_class.send(:define_method, name, method) } + end + + def territory(id, currency) + { 'type' => 'territories', 'id' => id, 'attributes' => { 'currency' => currency } } + end + + def test_subscription_snapshot_splits_current_and_scheduled + @responses['/v1/subscriptions/42/prices'] = { + 'data' => [ + { 'type' => 'subscriptionPrices', 'id' => 'p1', 'attributes' => { 'startDate' => nil }, + 'relationships' => { 'territory' => { 'data' => { 'id' => 'BRA' } }, + 'subscriptionPricePoint' => { 'data' => { 'type' => 'subscriptionPricePoints', 'id' => 'old' } } } }, + { 'type' => 'subscriptionPrices', 'id' => 'p2', 'attributes' => { 'startDate' => '2099-01-01' }, + 'relationships' => { 'territory' => { 'data' => { 'id' => 'BRA' } }, + 'subscriptionPricePoint' => { 'data' => { 'type' => 'subscriptionPricePoints', 'id' => 'new' } } } } + ], + 'included' => [ + { 'type' => 'subscriptionPricePoints', 'id' => 'old', 'attributes' => { 'customerPrice' => '59.90' } }, + { 'type' => 'subscriptionPricePoints', 'id' => 'new', 'attributes' => { 'customerPrice' => '34.90' } }, + territory('BRA', 'BRL') + ] + } + snap = Products.build('subscription', '42').snapshot('2026-10-30') + bra = snap['prices']['BRA'] + assert_equal 'old', bra['point_id'] + assert_equal 59.9, bra['price'] + assert_equal 'BRL', bra['currency'] + assert_equal ['new'], bra['scheduled'].map { |s| s['point_id'] } + assert_equal 'p2', bra['scheduled'].first['price_id'] + end + + def test_equalized_and_price_points_are_parsed + @responses['/equalizations'] = { + 'data' => [{ 'id' => 'e1', 'attributes' => { 'customerPrice' => '34.90' }, + 'relationships' => { 'territory' => { 'data' => { 'id' => 'BRA' } } } }], + 'included' => [territory('BRA', 'BRL')] + } + @responses['/pricePoints'] = { 'data' => [{ 'id' => 'a', 'attributes' => { 'customerPrice' => '1.99' } }, + { 'id' => 'b', 'attributes' => { 'customerPrice' => 'oops' } }] } + sub = Products.build('subscription', '42') + assert_equal({ 'BRA' => { 'price' => 34.9, 'currency' => 'BRL' } }, sub.equalized('base')) + assert_equal [{ 'id' => 'a', 'price' => 1.99 }], sub.price_points('BRA') + assert(@calls.any? { |t, _| t.include?('filter%5Bterritory%5D=BRA') }) + end + + def test_subscription_price_request_shape + @responses['/v1/subscriptionPrices'] = { 'data' => {} } + Products.build('subscription', '42').apply_one!({ 'price_point_id' => 'pp9' }, '2026-11-01', true) + target, body = @calls.last + assert_equal '/v1/subscriptionPrices', target + assert_equal({ 'startDate' => '2026-11-01', 'preserveCurrentPrice' => true }, body['data']['attributes']) + assert_equal 'pp9', body['data']['relationships']['subscriptionPricePoint']['data']['id'] + assert_equal '42', body['data']['relationships']['subscription']['data']['id'] + end + + def test_app_schedule_is_one_request_with_base_and_all_manual_prices + @responses['/v1/appPriceSchedules'] = { 'data' => {} } + rows = [{ 'price_point_id' => 'usa-pp' }, { 'price_point_id' => 'bra-pp' }] + Products.build('app', '7').apply_all!(rows, 'USA') + target, body = @calls.last + assert_equal '/v1/appPriceSchedules', target + assert_equal 'USA', body['data']['relationships']['baseTerritory']['data']['id'] + assert_equal 2, body['data']['relationships']['manualPrices']['data'].size + assert_equal %w[usa-pp bra-pp], body['included'].map { |i| i['relationships']['appPricePoint']['data']['id'] } + assert_equal(body['included'].map { |i| i['id'] }, body['data']['relationships']['manualPrices']['data'].map { |i| i['id'] }) + end + + def test_iap_schedule_uses_iap_resource_types + @responses['/v1/inAppPurchasePriceSchedules'] = { 'data' => {} } + Products.build('iap', '8').apply_all!([{ 'price_point_id' => 'x' }], 'USA') + _, body = @calls.last + assert_equal 'inAppPurchases', body['data']['relationships']['inAppPurchase']['data']['type'] + assert_equal 'inAppPurchasePrices', body['included'].first['type'] + end + + def test_unreadable_availability_means_unknown_not_empty + Asc.singleton_class.send(:define_method, :request) { |*| raise Asc::HttpError.new(404, '{}') } + assert_nil Products.build('subscription', '42').available_territories + 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/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' From 45070b59601c659674d101c0f3006095bfe6bf21 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 30 Oct 2026 13:30:04 +0300 Subject: [PATCH 2/9] fix: generate test keys for OpenSSL 3 The Asc tests built EC keys with generate_key and set the public key by hand. Keys are immutable since OpenSSL 3.0, so both failed on the CI Ruby. - generate keys with EC.generate, which works on old and new OpenSSL - verify the JWT signature with the key itself instead of a hand-built public key --- skills/territory-pricing/tests/asc_test.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/skills/territory-pricing/tests/asc_test.rb b/skills/territory-pricing/tests/asc_test.rb index a554290..b83fada 100644 --- a/skills/territory-pricing/tests/asc_test.rb +++ b/skills/territory-pricing/tests/asc_test.rb @@ -15,7 +15,7 @@ def with_key(pem) end def test_jwt_is_a_valid_es256_token - key = OpenSSL::PKey::EC.new('prime256v1').generate_key + 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('.') @@ -28,14 +28,12 @@ def test_jwt_is_a_valid_es256_token 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 - public_key = OpenSSL::PKey::EC.new(key.public_key.group) - public_key.public_key = key.public_key - assert public_key.verify(OpenSSL::Digest::SHA256.new, der, "#{header}.#{payload}") + 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.new('secp384r1').generate_key.to_pem) do |path| + with_key(OpenSSL::PKey::EC.generate('secp384r1').to_pem) do |path| assert_raises(Asc::Error) { Asc.private_key(path) } end end From caa1dd0d798d438f4392e664ba2f509398e5091e Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 30 Oct 2026 14:16:05 +0300 Subject: [PATCH 3/9] fix: resolve legacy price points by price A price in effect can sit on a legacy price point that Apple no longer lists. Its equalizations request returns 404, and a territory on such a point looked changed even when the price stayed the same. - map the current point onto the listed point with the same price - resolve the base point before reading equalizations - add regression tests --- skills/territory-pricing/scripts/estimate.rb | 14 +++++++++++--- skills/territory-pricing/scripts/lib/pricing.rb | 10 ++++++++++ skills/territory-pricing/tests/pricing_test.rb | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/skills/territory-pricing/scripts/estimate.rb b/skills/territory-pricing/scripts/estimate.rb index 4d6a288..1eba2da 100755 --- a/skills/territory-pricing/scripts/estimate.rb +++ b/skills/territory-pricing/scripts/estimate.rb @@ -76,14 +76,21 @@ def print_summary(meta, rows) base_entry = snap['prices'][base] abort "ERROR: no price in effect in base territory #{base}; pick another with --base" unless base_entry && base_entry['point_id'] + # The price in effect may sit on a legacy price point that Apple no longer + # lists; equalizations only accept a listed one, so resolve it by price. + points = { base => product.price_points(base) } + base_point_id = Pricing.resolve_point_id(base_entry['point_id'], base_entry['price'], points[base]) + abort "ERROR: the #{base} price #{base_entry['price']} matches no current price point; pick another with --base" unless points[base].any? { |p| p['id'] == base_point_id } + warn "reading equalized prices from #{base}..." - equalized = product.equalized(base_entry['point_id']) + equalized = product.equalized(base_point_id) equalized[base] ||= { 'price' => base_entry['price'], 'currency' => base_entry['currency'] } availability = product.available_territories territories = equalized.keys.sort - points = {} territories.each_with_index do |t, i| + next if points.key?(t) + warn format('reading price points [%d/%d] %s', i: i + 1, n: territories.size, t: t) points[t] = product.price_points(t) end @@ -105,7 +112,8 @@ def print_summary(meta, rows) inputs = territories.map do |t| cur = snap['prices'][t] || {} { 'territory' => t, 'currency' => equalized[t]['currency'] || cur['currency'], 'current' => cur['price'], - 'current_point_id' => cur['point_id'], 'equalized' => equalized[t]['price'], + 'current_point_id' => Pricing.resolve_point_id(cur['point_id'], cur['price'], points[t]), + 'equalized' => equalized[t]['price'], 'available' => availability.nil? || availability.include?(t), 'points' => points[t] } end rows = Pricing.build_proposal(inputs, opts[:strategy], data, popts) diff --git a/skills/territory-pricing/scripts/lib/pricing.rb b/skills/territory-pricing/scripts/lib/pricing.rb index eead1a4..f19f0ad 100644 --- a/skills/territory-pricing/scripts/lib/pricing.rb +++ b/skills/territory-pricing/scripts/lib/pricing.rb @@ -93,6 +93,16 @@ def nearest_point(target, points) points.min_by { |p| [(p['price'] - target).abs, p['price']] } 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 + # entries: {'territory','point_id','price','start_date','end_date',...} # -> [price in effect today or nil, scheduled future changes] def split_entries(entries, today) diff --git a/skills/territory-pricing/tests/pricing_test.rb b/skills/territory-pricing/tests/pricing_test.rb index bf5d752..4934dcb 100644 --- a/skills/territory-pricing/tests/pricing_test.rb +++ b/skills/territory-pricing/tests/pricing_test.rb @@ -114,6 +114,21 @@ def test_changed_compares_point_ids 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_split_entries_separates_current_from_scheduled entries = [ { 'start_date' => nil, 'end_date' => '2026-01-01', 'point_id' => 'a' }, From 41e71ccd10fbf0133a121888b629e3358a07db23 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 30 Oct 2026 14:19:04 +0300 Subject: [PATCH 4/9] refactor: simplify the territory-pricing skill Move the skill toward the vibe-aso model: the agent drives the App Store Connect API from written instructions, and code stays only where it must be exact. - merge the API client into one asc.rb and refuse POST, PATCH and DELETE without --write, so no write happens before consent - add plan.rb for the arithmetic only: strategies, price point snapping, clamp, legacy price point resolution and CSV edits - turn SKILL.md and asc-pricing-api.md into an agent playbook with the lessons of the first live run - read the bundled data snapshots in plan.rb; refresh_data.rb fetches new data on demand - drop estimate, apply, snapshot, list_products, the product adapters and their tests - ignore .DS_Store and the root territory-pricing run folder --- .gitignore | 2 + skills/territory-pricing/SKILL.md | 153 ++++------ .../references/asc-pricing-api.md | 134 +++++++-- skills/territory-pricing/references/setup.md | 2 +- .../references/strategies.md | 17 +- skills/territory-pricing/scripts/apply.rb | 176 ----------- skills/territory-pricing/scripts/asc.rb | 253 ++++++++++++++-- skills/territory-pricing/scripts/estimate.rb | 132 --------- skills/territory-pricing/scripts/lib/asc.rb | 171 ----------- .../scripts/lib/data_sources.rb | 149 ---------- .../territory-pricing/scripts/lib/products.rb | 275 ------------------ .../scripts/list_products.rb | 34 --- skills/territory-pricing/scripts/plan.rb | 112 +++++++ .../territory-pricing/scripts/refresh_data.rb | 131 ++++++++- skills/territory-pricing/scripts/snapshot.rb | 25 -- skills/territory-pricing/tests/asc_test.rb | 29 +- .../tests/data_sources_test.rb | 40 --- .../tests/fixtures/big_mac.csv | 7 - .../tests/fixtures/plan_input.json | 16 + .../tests/fixtures/tiers.json | 1 + skills/territory-pricing/tests/plan_test.rb | 87 ++++++ .../territory-pricing/tests/products_test.rb | 105 ------- 22 files changed, 764 insertions(+), 1287 deletions(-) create mode 100644 .gitignore delete mode 100755 skills/territory-pricing/scripts/apply.rb delete mode 100755 skills/territory-pricing/scripts/estimate.rb delete mode 100644 skills/territory-pricing/scripts/lib/asc.rb delete mode 100644 skills/territory-pricing/scripts/lib/data_sources.rb delete mode 100644 skills/territory-pricing/scripts/lib/products.rb delete mode 100755 skills/territory-pricing/scripts/list_products.rb create mode 100755 skills/territory-pricing/scripts/plan.rb delete mode 100755 skills/territory-pricing/scripts/snapshot.rb delete mode 100644 skills/territory-pricing/tests/data_sources_test.rb delete mode 100644 skills/territory-pricing/tests/fixtures/big_mac.csv create mode 100644 skills/territory-pricing/tests/fixtures/plan_input.json create mode 100644 skills/territory-pricing/tests/fixtures/tiers.json create mode 100644 skills/territory-pricing/tests/plan_test.rb delete mode 100644 skills/territory-pricing/tests/products_test.rb 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/skills/territory-pricing/SKILL.md b/skills/territory-pricing/SKILL.md index e982d4b..07efe81 100644 --- a/skills/territory-pricing/SKILL.md +++ b/skills/territory-pricing/SKILL.md @@ -5,26 +5,32 @@ description: Sets worldwide per-territory prices for an iOS app, in-app purchase # 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 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 is the anchor, usually USA. +* **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 (see `references/strategies.md`). +* **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.** `estimate.rb` only reads. `apply.rb` is a dry run unless `--confirm`. -2. **`--confirm` only after the user said yes** to the Price Proposal you showed in this conversation. An earlier "yes" for another proposal or product does not count. +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.** Report only what the read-back after the write confirmed. -6. Run scripts with absolute paths from this skill's directory; `SKILL_DIR` below is the folder holding this file. +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 @@ -34,26 +40,21 @@ nothing is written to App Store Connect until they agree. bash "$SKILL_DIR/scripts/check_setup.sh" ``` -Stop on any `FAIL` and walk the user through the fix it prints (details in `references/setup.md`). -The key lives in `~/.appstoreconnect/private_keys/AuthKey_.p8`, the Issuer ID in -`~/.appstoreconnect/config.json`. Continue only on `setup OK`. +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 -```bash -ruby "$SKILL_DIR/scripts/list_products.rb" --apps --bundle-id -ruby "$SKILL_DIR/scripts/list_products.rb" --app -``` - -If the working directory has an Xcode project, take the bundle id from it (`PRODUCT_BUNDLE_IDENTIFIER`); -otherwise list all apps. Ask which product to price: the app price, an in-app purchase, or a -subscription. For subscriptions, price **the whole subscription group** (every subscription in it) -with one start date, so a yearly plan never sits above a lifetime unlock for a day. -One kind and one group per run. +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, so a yearly plan never sits above a lifetime unlock +for a day. One kind and one group per run. ### 3. Choose the strategy and settings -Ask the user (AskUserQuestion). Read `references/strategies.md` for formulas and caveats first. +Ask the user (AskUserQuestion): | Strategy | Use when | |---|---| @@ -61,100 +62,70 @@ Ask the user (AskUserQuestion). Read `references/strategies.md` for formulas and | `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, a comparison | - -Also confirm the base territory (default: the schedule's base, else USA) and the guard rails: -clamp 30%..130% of the equalized price (`--clamp-min/--clamp-max`), flag changes above 25% -(`--flag-threshold`). Defaults are fine unless the user objects. +| `equalization` | Baseline only: Apple's own prices, for comparison | -### 4. Estimate (read-only) +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. -```bash -RUN="./territory-pricing//$(date +%Y-%m-%d-%H%M)" -ruby "$SKILL_DIR/scripts/estimate.rb" --kind --id \ - --strategy --base --out "$RUN" -``` - -It writes `before.json` (current prices, the rollback point), `proposal.json`, `proposal.csv` and -`price_points.json` into `$RUN`. For a subscription group run it once per subscription. +### 4. Collect the data (read-only) and build the Price Proposal -Show the user the printed summary: counts, data sources with dates, largest decreases and increases, -skipped territories, plus the path to `proposal.csv`. Point out flags (`clamped`, `large-change`), -territories that used a fallback (`ratio_source` other than the chosen strategy), and any warning -about already scheduled price changes. Offer to add `territory-pricing/` to `.gitignore`. - -If the user edits `proposal.csv` (change `proposed`; empty it to leave a territory alone), rebuild: +Follow the playbook to collect, per territory: current price and price point, equalized price, and the list +of price points. Save it as `plan-input.json`, then: ```bash -ruby "$SKILL_DIR/scripts/estimate.rb" --out "$RUN" --rebuild +ruby "$SKILL_DIR/scripts/plan.rb" --input "$RUN/plan-input.json" --out "$RUN" --strategy --base ``` -Then show the new summary. To try another strategy, estimate again into a new `$RUN`. - -### 5. Get consent - -Ask plainly: apply this proposal? Name the product, strategy, number of territories that change and -the largest decrease. Wait for a clear yes. Anything else: adjust and re-estimate. +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). -### 6. Apply +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. To try another strategy, run `plan.rb` again. -Dry run first and show it: +### 5. Get consent -```bash -ruby "$SKILL_DIR/scripts/apply.rb" --proposal "$RUN/proposal.json" -``` +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`. -**App price and in-app purchase** (one schedule, all prices at once, effective immediately): +### 6. Write -```bash -ruby "$SKILL_DIR/scripts/apply.rb" --proposal "$RUN/proposal.json" --confirm -``` +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`): -**Subscription** (one POST per territory; price changes that took effect cannot be undone): - -1. Warn explicitly and get a **second, separate yes**: new prices start on the start date, apply to - new subscribers, and cannot be reverted once live. By default existing subscribers keep their - price (`preserveCurrentPrice: true`); `--no-preserve` triggers Apple's price-increase consent - flow and needs the user to ask for it. -2. Canary on 1-2 territories, then check its read-back with the user: - ```bash - ruby "$SKILL_DIR/scripts/apply.rb" --proposal "$RUN/proposal.json" --confirm --phase canary --start-date - ``` -3. Only if the canary matches, apply the rest with the **same** `--start-date`: - ```bash - ruby "$SKILL_DIR/scripts/apply.rb" --proposal "$RUN/proposal.json" --confirm --phase rest --start-date - ``` - Do this for every subscription of the group with that date. `apply-log.json` makes a rerun resume - instead of repeating territories. +* **App price / in-app purchase:** one schedule with the base territory and every manual price. +* **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 -`apply.rb` reads the prices back (`after.json`, `verify.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. +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 -```bash -ruby "$SKILL_DIR/scripts/apply.rb" --rollback "$RUN/before.json" # dry run -ruby "$SKILL_DIR/scripts/apply.rb" --rollback "$RUN/before.json" --confirm -``` - -For subscriptions this first cancels still-scheduled changes. A change that already took effect cannot -be undone, only superseded by a new change, so say that before running it. +`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. The scripts send the base and all - manual prices together; never improvise a base-only call. +* 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. -* Territories with no data for the chosen strategy fall back (Big Mac, then PPP, then equalization) - and the table says which one was used. -* Economic data is fetched live and falls back to the bundled snapshot; the summary prints each source and - its date. Refresh the snapshot with `ruby "$SKILL_DIR/scripts/refresh_data.rb"`. +* 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`: endpoints per product kind and how the scripts use them +* `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 index cbaa31f..fbc3abd 100644 --- a/skills/territory-pricing/references/asc-pricing-api.md +++ b/skills/territory-pricing/references/asc-pricing-api.md @@ -1,46 +1,120 @@ -# App Store Connect pricing endpoints +# Playbook: App Store Connect pricing calls -What the scripts call, per product kind. Shapes follow Apple's App Store Connect API documentation. +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` and `Asc.path` 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; chosen by the user (`--base`) | +| 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 | `GET /v3/appPricePoints/{pp}/equalizations` | `GET /v1/inAppPurchasePricePoints/{pp}/equalizations` | `GET /v1/subscriptionPricePoints/{pp}/equalizations` | +| 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 | -| Territory availability (best effort) | `/v1/apps/{id}/appAvailabilityV2` -> `/v2/appAvailabilities/{id}/territoryAvailabilities` | `/v2/inAppPurchases/{id}/inAppPurchaseAvailability` -> `.../availableTerritories` | `/v1/subscriptions/{id}/subscriptionAvailability?include=availableTerritories` | +| Products of an app | the app itself | `GET /v1/apps/{id}/inAppPurchasesV2` | `GET /v1/apps/{id}/subscriptionGroups`, then `/v1/subscriptionGroups/{gid}/subscriptions` | -Availability is read best effort: if Apple's answer cannot be read, every territory is treated as available -and a warning is printed. +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. -## Writes +## Collecting the data for `plan.rb` -**App and IAP**: one POST with `baseTerritory` and a `manualPrices` entry (an `included` `appPrices` / -`inAppPurchasePrices` resource with a temporary `${priceN}` id) for every territory. A schedule posted without the -per-territory prices drops them back to Apple's automatic prices, so the scripts never post the base alone. -Territories left out of the proposal (unavailable, no price points) follow the automatic prices. +Build `plan-input.json` (format in `scripts/plan.rb`'s header): -**Subscription**: `POST /v1/subscriptionPrices` with `startDate` (default tomorrow) and -`preserveCurrentPrice` (default `true`: existing subscribers keep their price). There is no bulk endpoint. -Price changes that have taken effect cannot be reverted; scheduled ones can be cancelled -(`DELETE /v1/subscriptionPrices/{id}`), which is what `--rollback` does first. +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 (one request per territory, about 175, paginated): + `points: [{"id", "price"}]` with `price` as a number. +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. -## Verification +Put `"product": {"kind", "id", "name"}` and `"base_territory"` at the top of the file. -After a write `apply.rb` re-reads the product with the same calls as the snapshot and compares the price -point in effect (or, for subscriptions, a scheduled entry with the same start date) to the proposal. -Output: `after.json` and `verify.json` next to the proposal. +## Writing -## Rate limits and errors +Only after consent, always with `--write`; save every request body in the run folder. + +**App and in-app purchase: one request.** Include the base territory and a `manualPrices` entry for every +territory that has a `price_point_id` (changed or not; territories left out fall back to Apple's automatic +prices). Use temporary ids `${price0}`, `${price1}`, ... and one `included` resource per price: + +```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 swap the names: `appPriceSchedules` becomes `inAppPurchasePriceSchedules`, the relationship +`app` is `inAppPurchase` (type `inAppPurchases`), prices are `inAppPurchasePrices`, the price point relationship +is `inAppPurchasePricePoint` (type `inAppPurchasePricePoints`). Never post the base alone: that drops every +per-territory price back to Apple's automatic one. Prices take effect immediately. -The client retries HTTP 429 and 5xx with backoff (honoring `Retry-After`), up to four attempts. An -estimate for one product reads about 175 price-point lists, one request per territory, so it takes a minute or two. -`401` means the key does not match its ids, `403` means the key role is too low. +**Subscription: one request per territory.** -## Known unknowns +```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. + +## 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`) 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 -The documentation does not show every response of `automaticPrices` and the IAP availability endpoints. If a -current price cannot be resolved, the table shows `n/a` for it and treats the territory as changed. The -scripts were checked against stubbed responses in this documented shape and against the live API only for -authentication; run a dry run on a real product before the first write. +`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/setup.md b/skills/territory-pricing/references/setup.md index 8aa4ee2..af208b4 100644 --- a/skills/territory-pricing/references/setup.md +++ b/skills/territory-pricing/references/setup.md @@ -53,4 +53,4 @@ bash scripts/check_setup.sh * 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` is GET-only. Writes exist only in `scripts/apply.rb`, behind `--confirm`. +* `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 index 414654f..834e578 100644 --- a/skills/territory-pricing/references/strategies.md +++ b/skills/territory-pricing/references/strategies.md @@ -69,19 +69,20 @@ Unlisted territories get `default_tier`. `tier_ratios` and `default_tier` are op Ratio 1.0 everywhere: Apple's automatic prices. Use it as a baseline to see what a strategy changes. -## Options +## Options (`plan.rb`) | Flag | Default | Meaning | |---|---|---| -| `--base` | schedule base, else USA | anchor territory | +| `--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 | -| `--offline` | off | use only the bundled data snapshots | +| `--tiers FILE` | none | JSON for `--strategy tiered` | ## Data -Fetched live on every estimate and cached nowhere; on failure the bundled snapshot in `references/data/` -is used and the summary shows `snapshot` with its date. Sources: The Economist big-mac-data (GitHub), -World Bank Indicators API. Refresh the snapshot 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. +`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/apply.rb b/skills/territory-pricing/scripts/apply.rb deleted file mode 100755 index ba5e375..0000000 --- a/skills/territory-pricing/scripts/apply.rb +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Applies a Price Proposal to App Store Connect. DRY RUN unless --confirm. -# -# ruby apply.rb --proposal DIR/proposal.json # dry run -# ruby apply.rb --proposal DIR/proposal.json --confirm # app / iap -# ruby apply.rb --proposal DIR/proposal.json --confirm --phase canary # subscription: 1-2 territories -# ruby apply.rb --proposal DIR/proposal.json --confirm --phase rest # subscription: the rest -# ruby apply.rb --rollback DIR/before.json --confirm # back to the snapshot -# -# Every write is followed by a read-back from App Store Connect; only what the -# read-back confirms is reported as applied. -$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) -require 'optparse' -require 'json' -require 'date' -require 'products' - -opts = { phase: nil, canary: 2, preserve: true, confirm: false } -OptionParser.new do |o| - o.on('--proposal FILE') { |v| opts[:proposal] = v } - o.on('--rollback FILE', 'before.json snapshot to restore') { |v| opts[:rollback] = v } - o.on('--confirm', 'actually write to App Store Connect') { opts[:confirm] = true } - o.on('--phase PHASE', %w[all canary rest]) { |v| opts[:phase] = v } - o.on('--canary N', Integer) { |v| opts[:canary] = v } - o.on('--start-date DATE', 'subscriptions: YYYY-MM-DD, default tomorrow') { |v| opts[:start] = v } - o.on('--no-preserve', 'subscriptions: do NOT keep current subscribers on their price') { opts[:preserve] = false } -end.parse! -abort 'usage: see header of apply.rb' unless opts[:proposal] || opts[:rollback] - -def die(msg) - abort "ERROR: #{msg}" -end - -def money(value, currency) - value ? format('%.2f %s', value, currency) : 'n/a' -end - -def load_source(opts) - unless opts[:rollback] - doc = JSON.parse(File.read(opts[:proposal])) - return [doc['meta'], doc['rows'], File.dirname(opts[:proposal])] - end - - snap = JSON.parse(File.read(opts[:rollback])) - rows = snap['prices'].select { |_, p| p['point_id'] }.map do |t, p| - { 'territory' => t, 'price_point_id' => p['point_id'], 'proposed' => p['price'], 'currency' => p['currency'], - 'current_point_id' => nil, 'current' => nil, 'change_pct' => nil } - end - [{ 'kind' => snap['kind'], 'id' => snap['id'], 'base_territory' => snap['base_territory'] || 'USA' }, rows, File.dirname(opts[:rollback])] -end - -def future_date?(str, today) - Date.parse(str) > Date.parse(today) -rescue ArgumentError - false -end - -def read_log(path) - File.file?(path) ? JSON.parse(File.read(path)) : { 'applied' => {}, 'failed' => {} } -end - -def verify(product, rows, run_dir, start_date) - after = product.snapshot - File.write(File.join(run_dir, 'after.json'), JSON.pretty_generate(after)) - bad = rows.reject do |r| - p = after['prices'][r['territory']] || {} - p['point_id'] == r['price_point_id'] || - (start_date && (p['scheduled'] || []).any? { |s| s['start_date'] == start_date && s['point_id'] == r['price_point_id'] }) - end - report = { 'checked' => rows.size, 'ok' => rows.size - bad.size, - 'mismatches' => bad.map { |r| { 'territory' => r['territory'], 'expected' => r['proposed'], 'point_id' => r['price_point_id'] } } } - File.write(File.join(run_dir, 'verify.json'), JSON.pretty_generate(report)) - report -end - -begin - meta, rows, run_dir = load_source(opts) - kind = meta['kind'] - product = Products.build(kind, meta['id']) - base = meta['base_territory'] - subscription = kind == 'subscription' - opts[:phase] ||= subscription ? 'canary' : 'all' - die 'subscriptions must go through --phase canary, then --phase rest' if subscription && opts[:phase] == 'all' - die "--phase #{opts[:phase]} is for subscriptions only" if !subscription && opts[:phase] != 'all' - - today = Products.today - start = opts[:start] || (Date.parse(today) + 1).to_s - die "--start-date must be a date after #{today}" if subscription && !future_date?(start, today) - - cancel = [] - if opts[:rollback] - live = product.snapshot - # only cancel changes that were not already scheduled when the snapshot was taken - known = JSON.parse(File.read(opts[:rollback]))['prices'].values.flat_map { |p| (p['scheduled'] || []).map { |s| s['price_id'] } } - live['prices'].each do |t, p| - (p['scheduled'] || []).reject { |s| known.include?(s['price_id']) }.each { |s| cancel << [t, s] } if subscription - rows.each { |r| r['current_point_id'] = p['point_id'] if r['territory'] == t } - end - end - - todo = rows.select { |r| Pricing.changed?(r) } - submit = rows.select { |r| r['price_point_id'] } - puts "#{opts[:confirm] ? 'APPLY' : 'DRY RUN'}: #{kind} #{meta['id']} (#{opts[:rollback] ? 'rollback' : meta['strategy']}), base #{base}" - puts " #{todo.size} of #{rows.size} territories differ from the current price" - puts " will cancel #{cancel.size} scheduled subscription price change(s) first" unless cancel.empty? - if todo.empty? && cancel.empty? - puts 'Nothing to do.' - exit 0 - end - - if subscription - log_path = File.join(run_dir, 'apply-log.json') - log = read_log(log_path) - pending = todo.reject { |r| log['applied'].key?(r['territory']) } - canary = pending.reject { |r| r['territory'] == base }.first(opts[:canary]) - canary = pending.first(opts[:canary]) if canary.empty? - if opts[:phase] == 'canary' - batch = canary - else - die 'run --phase canary first and check its read-back' if log['applied'].empty? - batch = pending - end - puts " phase #{opts[:phase]}: #{batch.size} POST /v1/subscriptionPrices, startDate #{start}, preserveCurrentPrice #{opts[:preserve]}" - batch.first(opts[:confirm] ? 0 : 8).each do |r| - puts format(' %-4s %-14s -> %-14s', r['territory'], money(r['current'], r['currency']), money(r['proposed'], r['currency'])) - end - puts ' ...' if !opts[:confirm] && batch.size > 8 - unless opts[:confirm] - puts 'Dry run only. Nothing was written. Re-run with --confirm after the user agrees.' - exit 0 - end - - cancel.each { |_, s| product.cancel_scheduled!(s['price_id']) } - puts " cancelled #{cancel.size} scheduled price change(s)" unless cancel.empty? - if batch.empty? - puts 'No price writes needed: the prices in effect already match.' - exit 0 - end - batch.each do |r| - product.apply_one!(r, start, opts[:preserve]) - log['applied'][r['territory']] = { 'point_id' => r['price_point_id'], 'start_date' => start, 'at' => Time.now.utc.iso8601 } - log['failed'].delete(r['territory']) - puts " wrote #{r['territory']}" - rescue Asc::HttpError => e - log['failed'][r['territory']] = e.message - warn " FAILED #{r['territory']}: #{e.message}" - break if opts[:phase] == 'canary' - ensure - File.write(log_path, JSON.pretty_generate(log)) - end - # the final report covers everything applied so far, canary included - checked = (opts[:phase] == 'rest' ? todo : batch).select { |r| log['applied'].key?(r['territory']) } - else - puts " 1 POST creating a new price schedule: base #{base}, #{submit.size} manual prices (territories left out follow Apple's automatic prices)" - unless opts[:confirm] - puts 'Dry run only. Nothing was written. Re-run with --confirm after the user agrees.' - exit 0 - end - product.apply_all!(submit, base) - puts ' schedule created' - checked = submit - end - - report = verify(product, checked, run_dir, subscription ? start : nil) - puts "Read-back: #{report['ok']}/#{report['checked']} territories match what was sent" - report['mismatches'].first(20).each { |m| puts " MISMATCH #{m['territory']}: expected #{m['expected']}" } - failed = subscription ? log['failed'] : {} - failed.each { |t, msg| puts " FAILED #{t}: #{msg}" } - puts 'Subscriptions: new prices start on the start date; App Store Connect keeps showing the old price until then (scheduled change, not a failure).' if subscription - puts 'Next: run --phase rest after the user reviews this read-back.' if subscription && opts[:phase] == 'canary' - exit(report['mismatches'].empty? && failed.empty? ? 0 : 1) -rescue Asc::Error => e - abort "ERROR: #{e.message}" -end diff --git a/skills/territory-pricing/scripts/asc.rb b/skills/territory-pricing/scripts/asc.rb index 047aaed..33a9303 100755 --- a/skills/territory-pricing/scripts/asc.rb +++ b/skills/territory-pricing/scripts/asc.rb @@ -1,37 +1,230 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Read-only App Store Connect client for diagnostics. -# ruby asc.rb --credentials resolve and validate the key (no key material printed) -# ruby asc.rb GET '/v1/apps?limit=1' -# GET only: everything that writes lives in apply.rb behind --confirm. -$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) -require 'asc' - -begin - if ARGV[0] == '--credentials' - creds = Asc.credentials - Asc.private_key(creds.p8_path) - puts "key_id=#{creds.key_id}" - puts "issuer_id=#{creds.issuer_id}" - puts "p8_path=#{creds.p8_path}" - puts "config_path=#{Asc::CONFIG_PATH}" - exit 0 - end - - method, target = ARGV - abort 'usage: ruby asc.rb --credentials | GET ' unless method&.upcase == 'GET' && target - - begin - body = Asc.request(:get, target) - puts 'HTTP 200' - puts JSON.pretty_generate(body) if body - rescue Asc::HttpError => e +# 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) + + module_function + + # Env vars (ASC_KEY_ID, ASC_ISSUER_ID, ASC_P8) win over the key folder. + def credentials + @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 + now = Time.now.to_i + if @token.nil? || now > @token_at + 900 + @token = jwt(credentials, now) + @token_at = now + end + @token + 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}?#{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 res.body.to_s.empty? ? nil : JSON.parse(res.body) + end + 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') + + payload = body && JSON.parse(body.start_with?('@') ? File.read(body[1..-1]) : body) + 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 - exit 1 + 1 + rescue JSON::ParserError, Errno::ENOENT => e + usage("bad request body (#{e.class})") + 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 -rescue Asc::Error => e - warn "ERROR: #{e.message}" - exit 2 end + +exit(Asc.cli(ARGV)) if $PROGRAM_NAME == __FILE__ diff --git a/skills/territory-pricing/scripts/estimate.rb b/skills/territory-pricing/scripts/estimate.rb deleted file mode 100755 index 1eba2da..0000000 --- a/skills/territory-pricing/scripts/estimate.rb +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Builds a Price Proposal. Read-only: it never writes to App Store Connect. -# ruby estimate.rb --kind subscription --id 6447497832 --strategy gni_bands --out DIR -# ruby estimate.rb --out DIR --rebuild # re-read proposal.csv after hand edits -$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) -require 'optparse' -require 'fileutils' -require 'json' -require 'products' -require 'data_sources' - -opts = { strategy: 'gni_bands' } -overrides = {} -OptionParser.new do |o| - o.on('--kind KIND', Products::KINDS) { |v| opts[:kind] = v } - o.on('--id ID') { |v| opts[:id] = v } - o.on('--strategy S', Pricing::STRATEGIES) { |v| opts[:strategy] = v } - o.on('--base TERRITORY', 'base territory, default: the schedule base or USA') { |v| overrides[:base_territory] = v.upcase } - o.on('--out DIR') { |v| opts[:out] = v } - 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('--offline', 'use bundled data snapshots only') { opts[:offline] = true } - o.on('--rebuild', 'rebuild proposal.json from an edited proposal.csv') { opts[:rebuild] = true } -end.parse! -abort 'usage: see header of estimate.rb' unless opts[:out] - -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['kind']} #{meta['id']} | 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(', ')}" - moved = rows.select { |r| r['change_pct'] && Pricing.changed?(r) }.sort_by { |r| r['change_pct'] } - { 'Largest decreases' => moved.first(8), '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| r['proposed'].nil? } - puts "Skipped: #{skipped.map { |r| "#{r['territory']} (#{r['note']})" }.join(', ')}" unless skipped.empty? -end - -begin - FileUtils.mkdir_p(opts[:out]) - proposal_path = File.join(opts[:out], 'proposal.json') - points_path = File.join(opts[:out], 'price_points.json') - - if opts[:rebuild] - proposal = JSON.parse(File.read(proposal_path)) - popts = Pricing.options(overrides.merge(base_territory: proposal['meta']['base_territory'])) - proposal['rows'] = Pricing.apply_csv_edits(proposal['rows'], File.read(File.join(opts[:out], 'proposal.csv')), - JSON.parse(File.read(points_path)), popts) - File.write(proposal_path, JSON.pretty_generate(proposal)) - File.write(File.join(opts[:out], 'proposal.csv'), Pricing.to_csv(proposal['rows'])) - print_summary(proposal['meta'], proposal['rows']) - exit 0 - end - - abort 'usage: see header of estimate.rb' unless opts[:kind] && opts[:id] - product = Products.build(opts[:kind], opts[:id]) - - warn 'reading current prices...' - snap = product.snapshot - File.write(File.join(opts[:out], 'before.json'), JSON.pretty_generate(snap)) - base = overrides[:base_territory] || snap['base_territory'] || 'USA' - popts = Pricing.options(overrides.merge(base_territory: base)) - base_entry = snap['prices'][base] - abort "ERROR: no price in effect in base territory #{base}; pick another with --base" unless base_entry && base_entry['point_id'] - - # The price in effect may sit on a legacy price point that Apple no longer - # lists; equalizations only accept a listed one, so resolve it by price. - points = { base => product.price_points(base) } - base_point_id = Pricing.resolve_point_id(base_entry['point_id'], base_entry['price'], points[base]) - abort "ERROR: the #{base} price #{base_entry['price']} matches no current price point; pick another with --base" unless points[base].any? { |p| p['id'] == base_point_id } - - warn "reading equalized prices from #{base}..." - equalized = product.equalized(base_point_id) - equalized[base] ||= { 'price' => base_entry['price'], 'currency' => base_entry['currency'] } - availability = product.available_territories - - territories = equalized.keys.sort - territories.each_with_index do |t, i| - next if points.key?(t) - - warn format('reading price points [%d/%d] %s', i: i + 1, n: territories.size, t: t) - points[t] = product.price_points(t) - end - File.write(points_path, JSON.pretty_generate(points)) - - data, provenance = DataSources.load(DataSources.needed_for(opts[:strategy]), offline: opts[:offline]) - 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 - tier_ratios = (cfg['tier_ratios'] || {}).map { |k, v| [Integer(k), Float(v)] }.to_h - popts = popts.merge(tier_ratios: tier_ratios) unless tier_ratios.empty? - popts = popts.merge(default_tier: Integer(cfg['default_tier'])) if cfg['default_tier'] - end - missing = DataSources.needed_for(opts[:strategy]).select { |n| data[n].nil? } - abort "ERROR: no #{missing.join(', ')} data (live fetch failed and no bundled snapshot)" unless missing.empty? - - inputs = territories.map do |t| - cur = snap['prices'][t] || {} - { 'territory' => t, 'currency' => equalized[t]['currency'] || cur['currency'], 'current' => cur['price'], - 'current_point_id' => Pricing.resolve_point_id(cur['point_id'], cur['price'], points[t]), - 'equalized' => equalized[t]['price'], - 'available' => availability.nil? || availability.include?(t), 'points' => points[t] } - end - rows = Pricing.build_proposal(inputs, opts[:strategy], data, popts) - - meta = { 'kind' => opts[:kind], 'id' => opts[:id], 'strategy' => opts[:strategy], 'base_territory' => base, - 'created_at' => Time.now.utc.iso8601, 'data' => provenance, - 'options' => popts.reject { |k, _| %i[band_ratios tier_ratios].include?(k) } } - File.write(proposal_path, JSON.pretty_generate('meta' => meta, 'rows' => rows)) - File.write(File.join(opts[:out], 'proposal.csv'), Pricing.to_csv(rows)) - print_summary(meta, rows) - scheduled = snap['prices'].count { |_, p| !p['scheduled'].empty? } - puts "WARNING: #{scheduled} territories already have a scheduled price change." if scheduled.positive? - puts "Files: #{opts[:out]}/{before.json,proposal.json,proposal.csv,price_points.json}" -rescue Asc::Error => e - abort "ERROR: #{e.message}" -end diff --git a/skills/territory-pricing/scripts/lib/asc.rb b/skills/territory-pricing/scripts/lib/asc.rb deleted file mode 100644 index aa9e9ef..0000000 --- a/skills/territory-pricing/scripts/lib/asc.rb +++ /dev/null @@ -1,171 +0,0 @@ -# frozen_string_literal: true - -# Minimal App Store Connect API client: ES256 JWT, Ruby stdlib only. -# Never prints key material. -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) - - module_function - - # Env vars (ASC_KEY_ID, ASC_ISSUER_ID, ASC_P8) win over the key folder. - def credentials - @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 - now = Time.now.to_i - if @token.nil? || now > @token_at + 900 - @token = jwt(credentials, now) - @token_at = now - end - @token - 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}?#{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 res.body.to_s.empty? ? nil : JSON.parse(res.body) - end - 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 -end diff --git a/skills/territory-pricing/scripts/lib/data_sources.rb b/skills/territory-pricing/scripts/lib/data_sources.rb deleted file mode 100644 index 3557d83..0000000 --- a/skills/territory-pricing/scripts/lib/data_sources.rb +++ /dev/null @@ -1,149 +0,0 @@ -# frozen_string_literal: true - -require 'csv' -require 'json' -require 'net/http' -require 'uri' -require 'time' - -# Economic inputs for the pricing strategies. Live fetch first, bundled -# snapshot (references/data/) when the network or the source fails. -module DataSources - DATA_DIR = File.expand_path('../../references/data', __dir__) - 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 - - # -> [{ big_mac:, ppp:, gni:, band1_overrides: }, provenance] - def load(needed, offline: false) - data = { band1_overrides: band1_overrides } - provenance = {} - needed.each do |name| - values, meta = offline ? [nil, nil] : try_live(name) - values, meta = snapshot(name) unless values - data[name] = values - provenance[name.to_s] = meta - end - [data, provenance] - end - - def needed_for(strategy) - { 'big_mac' => %i[big_mac ppp], 'ppp' => %i[ppp], 'gni_bands' => %i[gni] }.fetch(strategy, []) - end - - def try_live(name) - send("fetch_#{name}") - rescue StandardError => e - warn "warn: live #{name} fetch failed (#{e.class}: #{e.message}); using bundled snapshot" - nil - end - - 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 = JSON.parse(get("#{WORLD_BANK}/country?format=json&per_page=400"))[1] - .reject { |c| c['region']['id'] == 'NA' }.map { |c| c['id'] } - 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 - - # ---- bundled snapshot --------------------------------------------------- - - def snapshot(name) - file = { big_mac: 'big-mac.csv', ppp: 'ppp.csv', gni: 'gni.csv' }.fetch(name) - path = File.join(DATA_DIR, file) - return [nil, { 'origin' => 'missing', 'as_of' => nil }] unless File.file?(path) - - rows = CSV.read(path, headers: true) - values = rows.each_with_object({}) { |r, h| h[r['iso_a3']] = Float(r['value']) } - [values, { 'origin' => 'snapshot', 'as_of' => rows.map { |r| r['as_of'] }.max, 'source' => "references/data/#{file}" }] - end - - def band1_overrides - path = File.join(DATA_DIR, 'gni-bands.json') - File.file?(path) ? JSON.parse(File.read(path))['band1_overrides'] : [] - end - - def write_snapshot(name, values, as_of) - file = { big_mac: 'big-mac.csv', ppp: 'ppp.csv', gni: 'gni.csv' }.fetch(name) - File.write(File.join(DATA_DIR, file), 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 diff --git a/skills/territory-pricing/scripts/lib/products.rb b/skills/territory-pricing/scripts/lib/products.rb deleted file mode 100644 index 91fb1c0..0000000 --- a/skills/territory-pricing/scripts/lib/products.rb +++ /dev/null @@ -1,275 +0,0 @@ -# frozen_string_literal: true - -require 'time' -require 'asc' -require 'pricing' - -# One adapter per product kind. Each answers the same questions (current -# prices, price points, equalized prices) and knows how to write prices back. -module Products - KINDS = %w[app iap subscription].freeze - - module_function - - def build(kind, id) - { 'app' => App, 'iap' => Iap, 'subscription' => Subscription }.fetch(kind) { raise Asc::Error, "unknown kind #{kind}" }.new(id) - end - - def today - Time.now.utc.strftime('%Y-%m-%d') - end - - # Products the user can price for one app: the app itself, its IAPs and - # every auto-renewable subscription, grouped by subscription group. - def list(app_id) - items = [{ 'kind' => 'app', 'id' => app_id, 'name' => 'App price (paid app)' }] - Asc.paginate(Asc.path("/v1/apps/#{app_id}/inAppPurchasesV2", 'limit' => 200))['data'].each do |d| - items << { 'kind' => 'iap', 'id' => d['id'], 'name' => d.dig('attributes', 'name'), - 'detail' => d.dig('attributes', 'inAppPurchaseType') } - end - Asc.paginate(Asc.path("/v1/apps/#{app_id}/subscriptionGroups", 'limit' => 200))['data'].each do |g| - Asc.paginate(Asc.path("/v1/subscriptionGroups/#{g['id']}/subscriptions", 'limit' => 200))['data'].each do |s| - items << { 'kind' => 'subscription', 'id' => s['id'], 'name' => s.dig('attributes', 'name'), - 'group' => g.dig('attributes', 'referenceName'), 'detail' => s.dig('attributes', 'subscriptionPeriod') } - end - end - items - end - - class Base - attr_reader :id - - def initialize(id) - @id = id - end - - # {'territory' => {'point_id','price','currency','start_date','scheduled' => [...]}} - def snapshot(today = Products.today) - prices = current_entries.group_by { |e| e['territory'] }.each_with_object({}) do |(territory, entries), out| - current, scheduled = Pricing.split_entries(entries, today) - out[territory] = (current || {}).reject { |k, _| k == 'end_date' }.merge('scheduled' => scheduled) - end - { 'kind' => kind, 'id' => id, 'fetched_at' => Time.now.utc.iso8601, 'base_territory' => base_territory, 'prices' => prices } - end - - # All price points of one territory: [{'id','price'}] - def price_points(territory) - points_from(Asc.paginate(Asc.path(price_points_path, 'filter[territory]' => territory, 'limit' => 200))) - end - - # Equalized price points of a base point, one per territory: - # {'BRA' => {'price' => 59.9, 'currency' => 'BRL'}} - def equalized(point_id) - resp = Asc.paginate(Asc.path(equalizations_path(point_id), 'include' => 'territory', 'limit' => 200)) - inc = Asc.index_included(resp['included']) - resp['data'].each_with_object({}) do |d, h| - territory = territory_of(d) - price = price_of(d) - next unless territory && price - - h[territory] = { 'price' => price, 'currency' => inc[['territories', territory]]&.dig('attributes', 'currency') } - end - end - - # Territories the product is for sale in, or nil when Apple's answer - # could not be read (callers then treat every territory as available). - def available_territories - fetch_available - rescue Asc::HttpError => e - warn "warn: could not read territory availability (#{e.message}); treating all territories as available" - nil - end - - private - - def price_of(resource) - v = resource.dig('attributes', 'customerPrice') || resource.dig('attributes', 'equalizationPrice') - v && Float(v, exception: false) - end - - def territory_of(resource) - resource.dig('relationships', 'territory', 'data', 'id') - end - - def points_from(resp) - resp['data'].map { |d| { 'id' => d['id'], 'price' => price_of(d) } }.select { |p| p['price'] } - end - - # price resources (appPrices / inAppPurchasePrices / subscriptionPrices) - # joined with their price point and territory - def entries_from(resp, point_rel) - inc = Asc.index_included(resp['included']) - resp['data'].map do |p| - ref = p.dig('relationships', point_rel, 'data') - point = ref && inc[[ref['type'], ref['id']]] - territory = territory_of(p) || (point && territory_of(point)) - { - 'territory' => territory, 'point_id' => ref && ref['id'], 'price' => point && price_of(point), - 'currency' => inc[['territories', territory]]&.dig('attributes', 'currency'), - 'start_date' => p.dig('attributes', 'startDate'), 'end_date' => p.dig('attributes', 'endDate'), - 'price_id' => p['id'] - } - end.select { |e| e['territory'] } - end - - def reference(type, id) - { 'data' => { 'type' => type, 'id' => id } } - end - end - - class App < Base - def kind - 'app' - end - - def schedule_id - @schedule_id ||= Asc.request(:get, "/v1/apps/#{id}/appPriceSchedule")['data']['id'] - end - - def base_territory - Asc.request(:get, "/v1/appPriceSchedules/#{schedule_id}/baseTerritory")['data']['id'] - end - - def current_entries - %w[manualPrices automaticPrices].flat_map do |rel| - resp = Asc.paginate(Asc.path("/v1/appPriceSchedules/#{schedule_id}/#{rel}", - 'include' => 'appPricePoint,territory', 'limit' => 200)) - entries_from(resp, 'appPricePoint') - end - end - - # One schedule with the base territory and every manual price. Posting - # the base alone would wipe the per-territory prices, so never do that. - def apply_all!(rows, base) - body = schedule_body('appPriceSchedules', 'app', 'apps', 'appPrices', 'appPricePoint', 'appPricePoints', rows, base) - Asc.request(:post, '/v1/appPriceSchedules', body) - end - - private - - def price_points_path - - "/v1/apps/#{id}/appPricePoints" - - end - - def equalizations_path(point_id) - - "/v3/appPricePoints/#{point_id}/equalizations" - - end - - def fetch_available - av = Asc.request(:get, "/v1/apps/#{id}/appAvailabilityV2")['data']['id'] - resp = Asc.paginate(Asc.path("/v2/appAvailabilities/#{av}/territoryAvailabilities", 'limit' => 200)) - resp['data'].select { |d| d.dig('attributes', 'available') }.map { |d| territory_of(d) }.compact - end - - def schedule_body(type, rel, rel_type, price_type, point_rel, point_type, rows, base) - included = rows.each_with_index.map do |row, i| - { 'type' => price_type, 'id' => "${price#{i}}", 'attributes' => { 'startDate' => nil }, - 'relationships' => { point_rel => reference(point_type, row['price_point_id']) } } - end - { 'data' => { 'type' => type, 'attributes' => {}, - 'relationships' => { rel => reference(rel_type, id), 'baseTerritory' => reference('territories', base), - 'manualPrices' => { 'data' => included.map { |i| { 'type' => price_type, 'id' => i['id'] } } } } }, - 'included' => included } - end - end - - class Iap < App - def kind - 'iap' - end - - def schedule_id - @schedule_id ||= Asc.request(:get, "/v2/inAppPurchases/#{id}/iapPriceSchedule")['data']['id'] - end - - def base_territory - Asc.request(:get, "/v1/inAppPurchasePriceSchedules/#{schedule_id}/baseTerritory")['data']['id'] - end - - def current_entries - %w[manualPrices automaticPrices].flat_map do |rel| - resp = Asc.paginate(Asc.path("/v1/inAppPurchasePriceSchedules/#{schedule_id}/#{rel}", - 'include' => 'inAppPurchasePricePoint,territory', 'limit' => 200)) - entries_from(resp, 'inAppPurchasePricePoint') - end - end - - def apply_all!(rows, base) - body = schedule_body('inAppPurchasePriceSchedules', 'inAppPurchase', 'inAppPurchases', 'inAppPurchasePrices', - 'inAppPurchasePricePoint', 'inAppPurchasePricePoints', rows, base) - Asc.request(:post, '/v1/inAppPurchasePriceSchedules', body) - end - - private - - def price_points_path - - "/v2/inAppPurchases/#{id}/pricePoints" - - end - - def equalizations_path(point_id) - - "/v1/inAppPurchasePricePoints/#{point_id}/equalizations" - - end - - def fetch_available - av = Asc.request(:get, "/v2/inAppPurchases/#{id}/inAppPurchaseAvailability")['data']['id'] - resp = Asc.paginate(Asc.path("/v1/inAppPurchaseAvailabilities/#{av}/availableTerritories", 'limit' => 200)) - resp['data'].map { |d| d['id'] } - end - end - - class Subscription < Base - def kind - 'subscription' - end - - # Subscriptions have no base territory in the API; the caller picks one. - def base_territory - nil - end - - def current_entries - resp = Asc.paginate(Asc.path("/v1/subscriptions/#{id}/prices", 'include' => 'subscriptionPricePoint,territory', 'limit' => 200)) - entries_from(resp, 'subscriptionPricePoint') - end - - # One call per territory; Apple has no bulk endpoint. - def apply_one!(row, start_date, preserve) - body = { 'data' => { 'type' => 'subscriptionPrices', - 'attributes' => { 'startDate' => start_date, 'preserveCurrentPrice' => preserve }, - 'relationships' => { 'subscription' => reference('subscriptions', id), - 'subscriptionPricePoint' => reference('subscriptionPricePoints', row['price_point_id']) } } } - Asc.request(:post, '/v1/subscriptionPrices', body) - end - - def cancel_scheduled!(price_id) - Asc.request(:delete, "/v1/subscriptionPrices/#{price_id}") - end - - private - - def price_points_path - - "/v1/subscriptions/#{id}/pricePoints" - - end - - def equalizations_path(point_id) - - "/v1/subscriptionPricePoints/#{point_id}/equalizations" - - end - - def fetch_available - av = Asc.request(:get, "/v1/subscriptions/#{id}/subscriptionAvailability?include=availableTerritories") - (av.dig('data', 'relationships', 'availableTerritories', 'data') || []).map { |t| t['id'] } - end - end -end diff --git a/skills/territory-pricing/scripts/list_products.rb b/skills/territory-pricing/scripts/list_products.rb deleted file mode 100755 index 62cd734..0000000 --- a/skills/territory-pricing/scripts/list_products.rb +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Read-only lookup of apps and their priceable products. -# ruby list_products.rb --apps [--bundle-id com.acme.app] -# ruby list_products.rb --app 6447402192 -$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) -require 'optparse' -require 'json' -require 'products' - -opts = {} -OptionParser.new do |o| - o.on('--apps') { opts[:apps] = true } - o.on('--bundle-id ID') { |v| opts[:bundle_id] = v } - o.on('--app ID') { |v| opts[:app] = v } -end.parse! -abort 'usage: ruby list_products.rb --apps [--bundle-id ID] | --app APP_ID' unless opts[:apps] || opts[:app] - -begin - if opts[:apps] - params = { 'limit' => 200, 'fields[apps]' => 'name,bundleId' } - params['filter[bundleId]'] = opts[:bundle_id] if opts[:bundle_id] - Asc.paginate(Asc.path('/v1/apps', params))['data'].each do |a| - puts format('%-12s %-40s %s', a['id'], a.dig('attributes', 'bundleId'), a.dig('attributes', 'name')) - end - else - Products.list(opts[:app]).each do |p| - puts format('%-13s %-12s %-28s %s', p['kind'], p['id'], p['name'], [p['group'], p['detail']].compact.join(' / ')) - end - end -rescue Asc::Error => e - abort "ERROR: #{e.message}" -end diff --git a/skills/territory-pricing/scripts/plan.rb b/skills/territory-pricing/scripts/plan.rb new file mode 100755 index 0000000..2306aeb --- /dev/null +++ b/skills/territory-pricing/scripts/plan.rb @@ -0,0 +1,112 @@ +#!/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": {...free-form...}, "base_territory": "USA", "territories": [ +# {"territory", "currency", "current", "current_point_id", "equalized", +# "available" (default true), "points": [{"id", "price"}]}]} +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'optparse' +require 'fileutils' +require 'json' +require 'csv' +require 'time' +require 'pricing' + +DATA_DIR = File.expand_path('../references/data', __dir__) +SNAPSHOTS = { big_mac: 'big-mac.csv', ppp: 'ppp.csv', gni: 'gni.csv' }.freeze +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('--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(DATA_DIR, SNAPSHOTS.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(', ')}" + 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| r['proposed'].nil? } + puts "Skipped: #{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)) + popts = Pricing.options(overrides.merge(base_territory: proposal['meta']['base_territory'])) + proposal['rows'] = Pricing.apply_csv_edits(proposal['rows'], File.read(csv_path), proposal['points'], 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' +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(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 + tier_ratios = (cfg['tier_ratios'] || {}).map { |k, v| [Integer(k), Float(v)] }.to_h + popts = popts.merge(tier_ratios: tier_ratios) unless tier_ratios.empty? + popts = popts.merge(default_tier: Integer(cfg['default_tier'])) if cfg['default_tier'] +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' => popts.reject { |k, _| %i[band_ratios tier_ratios].include?(k) } } +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 index 2da4e89..295258b 100755 --- a/skills/territory-pricing/scripts/refresh_data.rb +++ b/skills/territory-pricing/scripts/refresh_data.rb @@ -1,16 +1,123 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Refreshes the bundled snapshots in references/data/ from the live sources. -# Usage: ruby refresh_data.rb -$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) -require 'data_sources' - -%i[big_mac ppp gni].each do |name| - values, meta = DataSources.send("fetch_#{name}") - DataSources.write_snapshot(name, values, meta['as_of']) - puts "#{name}: #{values.size} countries, as of #{meta['as_of']}" -rescue StandardError => e - warn "#{name}: FAILED (#{e.class}: #{e.message}); snapshot left unchanged" - exit 1 +# 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' + +module DataSources + DATA_DIR = File.expand_path('../references/data', __dir__) + 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 = JSON.parse(get("#{WORLD_BANK}/country?format=json&per_page=400"))[1] + .reject { |c| c['region']['id'] == 'NA' }.map { |c| c['id'] } + 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 + + def write_snapshot(name, values, as_of) + file = { big_mac: 'big-mac.csv', ppp: 'ppp.csv', gni: 'gni.csv' }.fetch(name) + File.write(File.join(DATA_DIR, file), 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 + +if $PROGRAM_NAME == __FILE__ + %i[big_mac ppp gni].each do |name| + values, meta = DataSources.send("fetch_#{name}") + DataSources.write_snapshot(name, values, meta['as_of']) + puts "#{name}: #{values.size} countries, as of #{meta['as_of']}" + rescue StandardError => e + warn "#{name}: FAILED (#{e.class}: #{e.message}); snapshot left unchanged" + exit 1 + end end diff --git a/skills/territory-pricing/scripts/snapshot.rb b/skills/territory-pricing/scripts/snapshot.rb deleted file mode 100755 index d5173c2..0000000 --- a/skills/territory-pricing/scripts/snapshot.rb +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Writes the current per-territory prices of one product to a JSON file. -# ruby snapshot.rb --kind subscription --id 6447497832 --out before.json -$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) -require 'optparse' -require 'json' -require 'products' - -opts = {} -OptionParser.new do |o| - o.on('--kind KIND', Products::KINDS) { |v| opts[:kind] = v } - o.on('--id ID') { |v| opts[:id] = v } - o.on('--out FILE') { |v| opts[:out] = v } -end.parse! -abort 'usage: ruby snapshot.rb --kind app|iap|subscription --id ID --out FILE' unless opts.values_at(:kind, :id, :out).all? - -begin - snap = Products.build(opts[:kind], opts[:id]).snapshot - File.write(opts[:out], JSON.pretty_generate(snap)) - puts "#{snap['prices'].size} territories written to #{opts[:out]}" -rescue Asc::Error => e - abort "ERROR: #{e.message}" -end diff --git a/skills/territory-pricing/tests/asc_test.rb b/skills/territory-pricing/tests/asc_test.rb index b83fada..5fa6f28 100644 --- a/skills/territory-pricing/tests/asc_test.rb +++ b/skills/territory-pricing/tests/asc_test.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true require_relative 'test_helper' -require 'asc' +require_relative '../scripts/asc' +require 'open3' +require 'rbconfig' require 'openssl' require 'tmpdir' @@ -51,4 +53,29 @@ def test_path_encodes_brackets 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 end diff --git a/skills/territory-pricing/tests/data_sources_test.rb b/skills/territory-pricing/tests/data_sources_test.rb deleted file mode 100644 index a865c6f..0000000 --- a/skills/territory-pricing/tests/data_sources_test.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -require_relative 'test_helper' -require 'data_sources' - -class DataSourcesTest < Minitest::Test - def parsed - DataSources.parse_big_mac(File.read(File.join(__dir__, 'fixtures', 'big_mac.csv')), 2026) - end - - def test_big_mac_keeps_latest_fresh_rows_only - values, as_of = parsed - assert_equal '2026-07-01', as_of - assert_in_delta 6.22, values['USA'] - refute values.key?('RUS'), 'stale rows are dropped' - refute values.key?('BAD'), 'rows without a price are dropped' - end - - def test_euro_area_row_is_spread_over_members - values, = parsed - assert_in_delta 7.07, values['DEU'] - assert_in_delta 7.07, values['FRA'] - refute values.key?('EUZ') - end - - def test_needed_data_per_strategy - assert_equal %i[big_mac ppp], DataSources.needed_for('big_mac') - assert_equal %i[gni], DataSources.needed_for('gni_bands') - assert_equal [], DataSources.needed_for('equalization') - end - - def test_bundled_snapshots_load_offline - data, provenance = DataSources.load(%i[big_mac ppp gni], offline: true) - %i[big_mac ppp gni].each do |name| - assert data[name].size > 50, "#{name} snapshot is too small" - assert_equal 'snapshot', provenance[name.to_s]['origin'] - end - assert_includes data[:band1_overrides], 'JPN' - end -end diff --git a/skills/territory-pricing/tests/fixtures/big_mac.csv b/skills/territory-pricing/tests/fixtures/big_mac.csv deleted file mode 100644 index 7aad93e..0000000 --- a/skills/territory-pricing/tests/fixtures/big_mac.csv +++ /dev/null @@ -1,7 +0,0 @@ -date,iso_a3,currency_code,name,local_price,dollar_ex,dollar_price -2026-07-01,USA,USD,United States,6.22,1,6.22 -2026-07-01,BRA,BRL,Brazil,26.3,5.59,4.705 -2026-07-01,EUZ,EUR,Euro area,6.0,0.85,7.07 -2022-01-01,RUS,RUB,Russia,135,75,1.8 -2026-07-01,JPN,JPY,Japan,480,155,3.08 -2025-01-01,BAD,XXX,Broken,1,1, 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/plan_test.rb b/skills/territory-pricing/tests/plan_test.rb new file mode 100644 index 0000000..e8bc84f --- /dev/null +++ b/skills/territory-pricing/tests/plan_test.rb @@ -0,0 +1,87 @@ +# 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_nil r['ZZZ']['proposed'] + assert_equal 'unavailable', r['ZZZ']['note'] + 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 +end diff --git a/skills/territory-pricing/tests/products_test.rb b/skills/territory-pricing/tests/products_test.rb deleted file mode 100644 index c68e31b..0000000 --- a/skills/territory-pricing/tests/products_test.rb +++ /dev/null @@ -1,105 +0,0 @@ -# frozen_string_literal: true - -require_relative 'test_helper' -require 'products' - -# Canned App Store Connect responses (shapes follow Apple's documentation); -# Asc.paginate / Asc.request are stubbed, so nothing touches the network. -class ProductsTest < Minitest::Test - def setup - @original = { request: Asc.method(:request), paginate: Asc.method(:paginate) } - @calls = [] - calls = @calls - @responses = {} - responses = @responses - stub = lambda do |_name, target, body = nil| - calls << [target, body] - key = responses.keys.find { |k| target.include?(k) } or raise "unstubbed request: #{target}" - responses[key] - end - Asc.singleton_class.send(:define_method, :request) { |method, target, body = nil| stub.call(method, target, body) } - Asc.singleton_class.send(:define_method, :paginate) { |target| stub.call(:get, target) } - end - - def teardown - @original.each { |name, method| Asc.singleton_class.send(:define_method, name, method) } - end - - def territory(id, currency) - { 'type' => 'territories', 'id' => id, 'attributes' => { 'currency' => currency } } - end - - def test_subscription_snapshot_splits_current_and_scheduled - @responses['/v1/subscriptions/42/prices'] = { - 'data' => [ - { 'type' => 'subscriptionPrices', 'id' => 'p1', 'attributes' => { 'startDate' => nil }, - 'relationships' => { 'territory' => { 'data' => { 'id' => 'BRA' } }, - 'subscriptionPricePoint' => { 'data' => { 'type' => 'subscriptionPricePoints', 'id' => 'old' } } } }, - { 'type' => 'subscriptionPrices', 'id' => 'p2', 'attributes' => { 'startDate' => '2099-01-01' }, - 'relationships' => { 'territory' => { 'data' => { 'id' => 'BRA' } }, - 'subscriptionPricePoint' => { 'data' => { 'type' => 'subscriptionPricePoints', 'id' => 'new' } } } } - ], - 'included' => [ - { 'type' => 'subscriptionPricePoints', 'id' => 'old', 'attributes' => { 'customerPrice' => '59.90' } }, - { 'type' => 'subscriptionPricePoints', 'id' => 'new', 'attributes' => { 'customerPrice' => '34.90' } }, - territory('BRA', 'BRL') - ] - } - snap = Products.build('subscription', '42').snapshot('2026-10-30') - bra = snap['prices']['BRA'] - assert_equal 'old', bra['point_id'] - assert_equal 59.9, bra['price'] - assert_equal 'BRL', bra['currency'] - assert_equal ['new'], bra['scheduled'].map { |s| s['point_id'] } - assert_equal 'p2', bra['scheduled'].first['price_id'] - end - - def test_equalized_and_price_points_are_parsed - @responses['/equalizations'] = { - 'data' => [{ 'id' => 'e1', 'attributes' => { 'customerPrice' => '34.90' }, - 'relationships' => { 'territory' => { 'data' => { 'id' => 'BRA' } } } }], - 'included' => [territory('BRA', 'BRL')] - } - @responses['/pricePoints'] = { 'data' => [{ 'id' => 'a', 'attributes' => { 'customerPrice' => '1.99' } }, - { 'id' => 'b', 'attributes' => { 'customerPrice' => 'oops' } }] } - sub = Products.build('subscription', '42') - assert_equal({ 'BRA' => { 'price' => 34.9, 'currency' => 'BRL' } }, sub.equalized('base')) - assert_equal [{ 'id' => 'a', 'price' => 1.99 }], sub.price_points('BRA') - assert(@calls.any? { |t, _| t.include?('filter%5Bterritory%5D=BRA') }) - end - - def test_subscription_price_request_shape - @responses['/v1/subscriptionPrices'] = { 'data' => {} } - Products.build('subscription', '42').apply_one!({ 'price_point_id' => 'pp9' }, '2026-11-01', true) - target, body = @calls.last - assert_equal '/v1/subscriptionPrices', target - assert_equal({ 'startDate' => '2026-11-01', 'preserveCurrentPrice' => true }, body['data']['attributes']) - assert_equal 'pp9', body['data']['relationships']['subscriptionPricePoint']['data']['id'] - assert_equal '42', body['data']['relationships']['subscription']['data']['id'] - end - - def test_app_schedule_is_one_request_with_base_and_all_manual_prices - @responses['/v1/appPriceSchedules'] = { 'data' => {} } - rows = [{ 'price_point_id' => 'usa-pp' }, { 'price_point_id' => 'bra-pp' }] - Products.build('app', '7').apply_all!(rows, 'USA') - target, body = @calls.last - assert_equal '/v1/appPriceSchedules', target - assert_equal 'USA', body['data']['relationships']['baseTerritory']['data']['id'] - assert_equal 2, body['data']['relationships']['manualPrices']['data'].size - assert_equal %w[usa-pp bra-pp], body['included'].map { |i| i['relationships']['appPricePoint']['data']['id'] } - assert_equal(body['included'].map { |i| i['id'] }, body['data']['relationships']['manualPrices']['data'].map { |i| i['id'] }) - end - - def test_iap_schedule_uses_iap_resource_types - @responses['/v1/inAppPurchasePriceSchedules'] = { 'data' => {} } - Products.build('iap', '8').apply_all!([{ 'price_point_id' => 'x' }], 'USA') - _, body = @calls.last - assert_equal 'inAppPurchases', body['data']['relationships']['inAppPurchase']['data']['type'] - assert_equal 'inAppPurchasePrices', body['included'].first['type'] - end - - def test_unreadable_availability_means_unknown_not_empty - Asc.singleton_class.send(:define_method, :request) { |*| raise Asc::HttpError.new(404, '{}') } - assert_nil Products.build('subscription', '42').available_territories - end -end From f2997c23e4dd775aaffc80554564a416bcacff77 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 30 Oct 2026 16:30:45 +0300 Subject: [PATCH 5/9] feat: collect price points in resumable batches A subscription has about 800 price points per territory, so reading all territories takes about 700 requests. One sequential script ran for over 8 minutes, wrote its result only at the end and would be killed by the 10 minute shell limit. - add collect_points.rb: one batch of territories per run, read in parallel threads, one file per territory, resumes where it stopped - report failed territories and exit non-zero instead of hiding them - tell the agent in SKILL.md and the playbook to sweep in short commands and never in one long loop - add tests for batching, resume and failure handling --- skills/territory-pricing/SKILL.md | 5 +- .../references/asc-pricing-api.md | 28 +++++- .../scripts/collect_points.rb | 61 ++++++++++++ .../tests/collect_points_test.rb | 99 +++++++++++++++++++ 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100755 skills/territory-pricing/scripts/collect_points.rb create mode 100644 skills/territory-pricing/tests/collect_points_test.rb diff --git a/skills/territory-pricing/SKILL.md b/skills/territory-pricing/SKILL.md index 07efe81..fafc0c3 100644 --- a/skills/territory-pricing/SKILL.md +++ b/skills/territory-pricing/SKILL.md @@ -70,7 +70,10 @@ 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. Save it as `plan-input.json`, then: +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`, then: ```bash ruby "$SKILL_DIR/scripts/plan.rb" --input "$RUN/plan-input.json" --out "$RUN" --strategy --base diff --git a/skills/territory-pricing/references/asc-pricing-api.md b/skills/territory-pricing/references/asc-pricing-api.md index fbc3abd..9a05f13 100644 --- a/skills/territory-pricing/references/asc-pricing-api.md +++ b/skills/territory-pricing/references/asc-pricing-api.md @@ -42,8 +42,8 @@ Build `plan-input.json` (format in `scripts/plan.rb`'s header): 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 (one request per territory, about 175, paginated): - `points: [{"id", "price"}]` with `price` as a number. +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 @@ -54,6 +54,30 @@ Build `plan-input.json` (format in `scripts/plan.rb`'s header): Put `"product": {"kind", "id", "name"}` and `"base_territory"` at the top of the file. +### 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. diff --git a/skills/territory-pricing/scripts/collect_points.rb b/skills/territory-pricing/scripts/collect_points.rb new file mode 100755 index 0000000..dcf9aa6 --- /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 Asc::Error => e + failed << "#{t}: #{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/tests/collect_points_test.rb b/skills/territory-pricing/tests/collect_points_test.rb new file mode 100644 index 0000000..ad82c84 --- /dev/null +++ b/skills/territory-pricing/tests/collect_points_test.rb @@ -0,0 +1,99 @@ +# 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_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 From 74bae4eca5003f14cdf9ce63d8f386d30c4bc9f9 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 30 Oct 2026 16:37:41 +0300 Subject: [PATCH 6/9] feat: add locale-aware price endings The nearest Apple price point often looks random: a territory has about 800 of them. Add an endings option that picks the nearest point with an ending customary in the currency. - add plan.rb --endings charm|none, --endings-tolerance (default 5%) and --endings-file - add references/data/endings.json with the conventions per currency: .99/.49 by default, .90 for BRL and ILS, .98 for SGD, round numbers for JPY, KRW, INR, MXN and CNY - let the window reach past the clamp bounds, otherwise band 3 territories sitting on the clamp floor could only round up - ask for the endings next to the strategy in SKILL.md - add tests, including the shipped rules file --- skills/territory-pricing/SKILL.md | 13 ++- .../references/data/endings.json | 74 ++++++++++++++++ .../references/strategies.md | 38 +++++++++ .../territory-pricing/scripts/lib/pricing.rb | 54 +++++++++++- skills/territory-pricing/scripts/plan.rb | 9 +- skills/territory-pricing/tests/plan_test.rb | 25 ++++++ .../territory-pricing/tests/pricing_test.rb | 84 +++++++++++++++++++ 7 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 skills/territory-pricing/references/data/endings.json diff --git a/skills/territory-pricing/SKILL.md b/skills/territory-pricing/SKILL.md index fafc0c3..9c459ce 100644 --- a/skills/territory-pricing/SKILL.md +++ b/skills/territory-pricing/SKILL.md @@ -64,6 +64,17 @@ Ask the user (AskUserQuestion): | `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. @@ -76,7 +87,7 @@ or your own sequential loop: the shell kills a command after 10 minutes and ever Save the result as `plan-input.json`, then: ```bash -ruby "$SKILL_DIR/scripts/plan.rb" --input "$RUN/plan-input.json" --out "$RUN" --strategy --base +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 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/strategies.md b/skills/territory-pricing/references/strategies.md index 834e578..0309fff 100644 --- a/skills/territory-pricing/references/strategies.md +++ b/skills/territory-pricing/references/strategies.md @@ -69,6 +69,41 @@ Unlisted territories get `default_tier`. `tier_ratios` and `default_tier` are op 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 | @@ -78,6 +113,9 @@ Ratio 1.0 everywhere: Apple's automatic prices. Use it as a baseline to see what | `--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 diff --git a/skills/territory-pricing/scripts/lib/pricing.rb b/skills/territory-pricing/scripts/lib/pricing.rb index f19f0ad..ffb3b05 100644 --- a/skills/territory-pricing/scripts/lib/pricing.rb +++ b/skills/territory-pricing/scripts/lib/pricing.rb @@ -16,9 +16,16 @@ module Pricing 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 + default_tier: 1, + endings: 'none', + endings_tolerance: 0.05, + 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 @@ -133,12 +140,53 @@ def build_row(input, strategy, data, opts) end applied = clamp_ratio(ratio, opts) target = input['equalized'] * applied - point = nearest_point(target, input['points']) + point, note = if source == 'base' + [nearest_point(target, input['points']), ''] + else + pick_point(target, input['points'], input['currency'], opts) + end row.merge!('ratio_source' => source, 'ratio' => applied.round(4), 'clamped' => applied != ratio, - 'target' => target.round(2), 'price_point_id' => point['id'], 'proposed' => point['price']) + 'target' => target.round(2), 'price_point_id' => point['id'], 'proposed' => point['price'], 'note' => note) finish(row, 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? + + [nice.min_by { |p| [(p['price'] - target).abs, p['price']] }, ''] + 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 + def skip(row, note) row.merge('note' => note) end diff --git a/skills/territory-pricing/scripts/plan.rb b/skills/territory-pricing/scripts/plan.rb index 2306aeb..6b5c33c 100755 --- a/skills/territory-pricing/scripts/plan.rb +++ b/skills/territory-pricing/scripts/plan.rb @@ -33,6 +33,9 @@ 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]) @@ -52,6 +55,8 @@ def print_summary(meta, rows) (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| @@ -80,6 +85,8 @@ def print_summary(meta, rows) input = JSON.parse(File.read(opts[:input])) base = overrides[:base_territory] || input['base_territory'] || 'USA' +endings_file = opts[:endings_file] || File.join(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), @@ -104,7 +111,7 @@ def print_summary(meta, rows) 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' => popts.reject { |k, _| %i[band_ratios tier_ratios].include?(k) } } + 'options' => popts.reject { |k, _| %i[band_ratios tier_ratios endings_rules].include?(k) } } 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)) diff --git a/skills/territory-pricing/tests/plan_test.rb b/skills/territory-pricing/tests/plan_test.rb index e8bc84f..cad85f7 100644 --- a/skills/territory-pricing/tests/plan_test.rb +++ b/skills/territory-pricing/tests/plan_test.rb @@ -84,4 +84,29 @@ def test_input_without_the_base_territory_is_rejected 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 end + diff --git a/skills/territory-pricing/tests/pricing_test.rb b/skills/territory-pricing/tests/pricing_test.rb index 4934dcb..e22bb30 100644 --- a/skills/territory-pricing/tests/pricing_test.rb +++ b/skills/territory-pricing/tests/pricing_test.rb @@ -172,4 +172,88 @@ def test_summary_counts 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 end + From a5d41e6b1d3d95c8ea373cc35a381b0760d21dde Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 30 Oct 2026 16:47:22 +0300 Subject: [PATCH 7/9] fix: address the code review findings A review of the territory-pricing branch found ten defects, three of which could change live prices. - keep the current price point on skipped rows so an app or IAP schedule never drops a manual price - merge partial tier ratios with the defaults and reject a tier without a ratio instead of pricing it at 100% - never clamp the base territory's ratio - rebuild with the options of the original run - read comma-decimal and currency-symbol prices in proposal.csv, name unreadable cells and write nothing until they are fixed - reset ratio, target and clamped flag on edited rows - add & to a path that already has a query and report any worker error in collect_points.rb - use github.ref in the CI concurrency groups - drop the unused split_entries - fetch the World Bank country list once and share the snapshot paths in one module - add a test for each fix --- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- skills/territory-pricing/SKILL.md | 4 +- .../references/asc-pricing-api.md | 9 +- skills/territory-pricing/scripts/asc.rb | 2 +- .../scripts/collect_points.rb | 4 +- .../territory-pricing/scripts/lib/pricing.rb | 82 +++++++++++----- .../scripts/lib/snapshots.rb | 8 ++ skills/territory-pricing/scripts/plan.rb | 33 ++++--- .../territory-pricing/scripts/refresh_data.rb | 15 ++- skills/territory-pricing/tests/asc_test.rb | 4 + .../tests/collect_points_test.rb | 14 +++ skills/territory-pricing/tests/plan_test.rb | 77 ++++++++++++++- .../territory-pricing/tests/pricing_test.rb | 95 ++++++++++++++++--- .../tests/refresh_data_test.rb | 44 +++++++++ 15 files changed, 329 insertions(+), 66 deletions(-) create mode 100644 skills/territory-pricing/scripts/lib/snapshots.rb create mode 100644 skills/territory-pricing/tests/refresh_data_test.rb diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 398e45c..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: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1898c08..40fca24 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ on: - "skills/territory-pricing/**" concurrency: - group: test-${{ github.head_ref }} + group: test-${{ github.head_ref || github.ref }} cancel-in-progress: true permissions: diff --git a/skills/territory-pricing/SKILL.md b/skills/territory-pricing/SKILL.md index 9c459ce..96f63a0 100644 --- a/skills/territory-pricing/SKILL.md +++ b/skills/territory-pricing/SKILL.md @@ -96,7 +96,9 @@ out flags (`clamped`, `large-change`) and territories that used a fallback (`rat chosen strategy). 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. To try another strategy, run `plan.rb` again. +`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 diff --git a/skills/territory-pricing/references/asc-pricing-api.md b/skills/territory-pricing/references/asc-pricing-api.md index 9a05f13..fa639db 100644 --- a/skills/territory-pricing/references/asc-pricing-api.md +++ b/skills/territory-pricing/references/asc-pricing-api.md @@ -11,8 +11,8 @@ $A POST /v1/subscriptionPrices @body.json --write # writes need --writ ``` 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` and `Asc.path` for a -throwaway collection script (keep it in the scratchpad, not in the repo). +`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 @@ -83,8 +83,9 @@ it. Reuse `$RUN/points/` when you re-run a plan for the same product on the same Only after consent, always with `--write`; save every request body in the run folder. **App and in-app purchase: one request.** Include the base territory and a `manualPrices` entry for every -territory that has a `price_point_id` (changed or not; territories left out fall back to Apple's automatic -prices). Use temporary ids `${price0}`, `${price1}`, ... and one `included` resource per price: +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. Use temporary ids `${price0}`, `${price1}`, ... and one `included` resource per price: ```json { diff --git a/skills/territory-pricing/scripts/asc.rb b/skills/territory-pricing/scripts/asc.rb index 33a9303..ad7ae71 100755 --- a/skills/territory-pricing/scripts/asc.rb +++ b/skills/territory-pricing/scripts/asc.rb @@ -127,7 +127,7 @@ def enc(value) def path(base, params = {}) return base if params.empty? - "#{base}?#{params.map { |k, v| "#{enc(k)}=#{enc(v)}" }.join('&')}" + "#{base}#{base.include?('?') ? '&' : '?'}#{params.map { |k, v| "#{enc(k)}=#{enc(v)}" }.join('&')}" end def request(method, target, body = nil) diff --git a/skills/territory-pricing/scripts/collect_points.rb b/skills/territory-pricing/scripts/collect_points.rb index dcf9aa6..929bd97 100755 --- a/skills/territory-pricing/scripts/collect_points.rb +++ b/skills/territory-pricing/scripts/collect_points.rb @@ -46,8 +46,8 @@ 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 Asc::Error => e - failed << "#{t}: #{e.message}" + rescue StandardError => e + failed << "#{t}: #{e.is_a?(Asc::Error) ? e.message : "#{e.class}: #{e.message}"}" end end end diff --git a/skills/territory-pricing/scripts/lib/pricing.rb b/skills/territory-pricing/scripts/lib/pricing.rb index ffb3b05..99c4b2a 100644 --- a/skills/territory-pricing/scripts/lib/pricing.rb +++ b/skills/territory-pricing/scripts/lib/pricing.rb @@ -110,16 +110,6 @@ def resolve_point_id(point_id, price, points) match && (match['price'] - price).abs < 0.005 ? match['id'] : point_id end - # entries: {'territory','point_id','price','start_date','end_date',...} - # -> [price in effect today or nil, scheduled future changes] - def split_entries(entries, today) - live = entries.select do |e| - (e['start_date'].nil? || e['start_date'] <= today) && (e['end_date'].nil? || e['end_date'] > today) - end - scheduled = entries.select { |e| e['start_date'] && e['start_date'] > today } - [live.max_by { |e| e['start_date'] || '' }, scheduled.sort_by { |e| e['start_date'] }] - end - # input: {'territory','currency','current','current_point_id','equalized','available','points'} def build_row(input, strategy, data, opts) row = { @@ -138,7 +128,7 @@ def build_row(input, strategy, data, opts) else ratio_for(strategy, input['territory'], data, opts) end - applied = clamp_ratio(ratio, opts) + applied = source == 'base' ? ratio : clamp_ratio(ratio, opts) target = input['equalized'] * applied point, note = if source == 'base' [nearest_point(target, input['points']), ''] @@ -187,8 +177,16 @@ def round_number?(price) (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) + 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) @@ -210,7 +208,7 @@ def changed?(row) end def summary(rows) - priced = rows.select { |r| r['proposed'] } + priced = rows.reject { |r| skipped?(r) } changed = rows.select { |r| changed?(r) } { 'territories' => rows.size, @@ -219,7 +217,7 @@ def summary(rows) '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| r['proposed'].nil? }, + 'skipped' => rows.count { |r| skipped?(r) }, 'sources' => priced.each_with_object(Hash.new(0)) { |r, h| h[r['ratio_source']] += 1 } } end @@ -231,27 +229,63 @@ def to_csv(rows) 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' -> 1299.0 etc.; nil when + # unreadable or not positive. The last separator is the decimal one, except + # a lone separator with exactly three digits after it ('1,299'): thousands. + def parse_price(raw) + s = raw.to_s.gsub(/[^\d.,]/, '') + return nil unless s =~ /\d/ + + last = [s.rindex(','), s.rindex('.')].compact.max + value = if last.nil? + s + elsif s[(last + 1)..-1].length == 3 && s.count(',.') == 1 + s.delete(',.') + else + "#{s[0...last].delete(',.')}.#{s[(last + 1)..-1]}" + end + price = Float(value, exception: false) + price&.positive? ? price : nil + 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) - edited = CSV.parse(csv_text, headers: true).each_with_object({}) { |r, h| h[r['territory']] = r['proposed'] } + edited = CSV.parse(csv_text, headers: true).each_with_object({}) { |r, h| h[r['territory']] = r['proposed'].to_s.strip } + problems = edited.reject { |_, raw| raw.empty? || parse_price(raw) }.map { |t, raw| "#{t}: '#{raw}'" } + raise EditError, problems unless problems.empty? + rows.map do |row| - next row unless edited.key?(row['territory']) + raw = edited[row['territory']] + next row if raw.nil? + next keep_current(row, opts) if raw.empty? - raw = edited[row['territory']].to_s.strip - if raw.empty? - next row['proposed'].nil? ? row : row.merge('price_point_id' => row['current_point_id'], - 'proposed' => row['current'], 'flag' => '', 'note' => 'left unchanged by user') - end - value = Float(raw) - next row if row['proposed'] && (value - row['proposed']).abs < 0.005 + value = parse_price(raw) + next row if row['proposed'] && (value - row['proposed']).abs < 0.005 && !skipped?(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', - 'note' => 'edited by user'), opts) + 'clamped' => false, 'ratio' => nil, 'target' => value.round(2), 'note' => 'edited by user'), opts) end 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/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 index 6b5c33c..890300c 100755 --- a/skills/territory-pricing/scripts/plan.rb +++ b/skills/territory-pricing/scripts/plan.rb @@ -17,9 +17,8 @@ require 'csv' require 'time' require 'pricing' +require 'snapshots' -DATA_DIR = File.expand_path('../references/data', __dir__) -SNAPSHOTS = { big_mac: 'big-mac.csv', ppp: 'ppp.csv', gni: 'gni.csv' }.freeze NEEDED = { 'big_mac' => %i[big_mac ppp], 'ppp' => %i[ppp], 'gni_bands' => %i[gni] }.freeze opts = { strategy: 'gni_bands' } @@ -41,7 +40,7 @@ abort 'usage: see header of plan.rb' unless opts[:out] && (opts[:input] || opts[:rebuild]) def load_snapshot(name) - rows = CSV.read(File.join(DATA_DIR, SNAPSHOTS.fetch(name)), headers: true) + 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 @@ -65,8 +64,8 @@ def print_summary(meta, rows) 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| r['proposed'].nil? } - puts "Skipped: #{skipped.map { |r| "#{r['territory']} (#{r['note']})" }.join(', ')}" unless skipped.empty? + 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]) @@ -75,8 +74,17 @@ def print_summary(meta, rows) if opts[:rebuild] proposal = JSON.parse(File.read(proposal_path)) - popts = Pricing.options(overrides.merge(base_territory: proposal['meta']['base_territory'])) - proposal['rows'] = Pricing.apply_csv_edits(proposal['rows'], File.read(csv_path), proposal['points'], popts) + # 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'] = popts.reject { |k, _| %i[band_ratios tier_ratios endings_rules].include?(k) } File.write(proposal_path, JSON.pretty_generate(proposal)) File.write(csv_path, Pricing.to_csv(proposal['rows'])) print_summary(proposal['meta'], proposal['rows']) @@ -85,7 +93,7 @@ def print_summary(meta, rows) input = JSON.parse(File.read(opts[:input])) base = overrides[:base_territory] || input['base_territory'] || 'USA' -endings_file = opts[:endings_file] || File.join(DATA_DIR, 'endings.json') +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| @@ -95,7 +103,7 @@ def print_summary(meta, rows) 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(DATA_DIR, 'gni-bands.json')))['band1_overrides'] } +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' @@ -103,9 +111,12 @@ def print_summary(meta, rows) cfg = JSON.parse(File.read(opts[:tiers])) data[:tiers] = (cfg['territories'] || {}).map { |t, tier| [t, Integer(tier)] }.to_h - tier_ratios = (cfg['tier_ratios'] || {}).map { |k, v| [Integer(k), Float(v)] }.to_h - popts = popts.merge(tier_ratios: tier_ratios) unless tier_ratios.empty? + # 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) diff --git a/skills/territory-pricing/scripts/refresh_data.rb b/skills/territory-pricing/scripts/refresh_data.rb index 295258b..4b141c5 100755 --- a/skills/territory-pricing/scripts/refresh_data.rb +++ b/skills/territory-pricing/scripts/refresh_data.rb @@ -11,9 +11,10 @@ require 'net/http' require 'uri' require 'time' +require 'set' +require_relative 'lib/snapshots' module DataSources - DATA_DIR = File.expand_path('../references/data', __dir__) 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 @@ -92,8 +93,7 @@ def fetch_gni # -> {'BRA' => {2024 => value, ...}}, countries only (no aggregates) def world_bank(indicator) - countries = JSON.parse(get("#{WORLD_BANK}/country?format=json&per_page=400"))[1] - .reject { |c| c['region']['id'] == 'NA' }.map { |c| c['id'] } + 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']) @@ -102,9 +102,14 @@ def world_bank(indicator) 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 + def write_snapshot(name, values, as_of) - file = { big_mac: 'big-mac.csv', ppp: 'ppp.csv', gni: 'gni.csv' }.fetch(name) - File.write(File.join(DATA_DIR, file), CSV.generate do |csv| + 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) diff --git a/skills/territory-pricing/tests/asc_test.rb b/skills/territory-pricing/tests/asc_test.rb index 5fa6f28..e940b46 100644 --- a/skills/territory-pricing/tests/asc_test.rb +++ b/skills/territory-pricing/tests/asc_test.rb @@ -50,6 +50,10 @@ 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 diff --git a/skills/territory-pricing/tests/collect_points_test.rb b/skills/territory-pricing/tests/collect_points_test.rb index ad82c84..88484e9 100644 --- a/skills/territory-pricing/tests/collect_points_test.rb +++ b/skills/territory-pricing/tests/collect_points_test.rb @@ -82,6 +82,20 @@ def test_points_are_stored_as_numbers 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' diff --git a/skills/territory-pricing/tests/plan_test.rb b/skills/territory-pricing/tests/plan_test.rb index cad85f7..d1aaf88 100644 --- a/skills/territory-pricing/tests/plan_test.rb +++ b/skills/territory-pricing/tests/plan_test.rb @@ -48,8 +48,9 @@ def test_base_keeps_its_price_and_unavailable_is_skipped r = rows(dir) assert_equal 'base', r['USA']['ratio_source'] assert_equal 11.99, r['USA']['proposed'] - assert_nil r['ZZZ']['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 @@ -108,5 +109,79 @@ def test_unknown_ending_mode_is_rejected 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 index e22bb30..d19b69e 100644 --- a/skills/territory-pricing/tests/pricing_test.rb +++ b/skills/territory-pricing/tests/pricing_test.rb @@ -96,10 +96,87 @@ def test_base_territory_keeps_its_price assert_equal 9.99, row['proposed'] end - def test_unavailable_territory_is_skipped - row = Pricing.build_row(input('BRA', 10.0, points(5), 'available' => false), 'gni_bands', DATA, OPTS) - assert_nil row['proposed'] + 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 @@ -129,18 +206,6 @@ def test_unchanged_price_on_a_legacy_point_is_not_a_change refute Pricing.changed?(rows.first) end - def test_split_entries_separates_current_from_scheduled - entries = [ - { 'start_date' => nil, 'end_date' => '2026-01-01', 'point_id' => 'a' }, - { 'start_date' => nil, 'end_date' => nil, 'point_id' => 'b' }, - { 'start_date' => '2026-05-01', 'end_date' => nil, 'point_id' => 'c' }, - { 'start_date' => '2099-01-01', 'end_date' => nil, 'point_id' => 'd' } - ] - current, scheduled = Pricing.split_entries(entries, '2026-10-30') - assert_equal 'c', current['point_id'] - assert_equal ['d'], scheduled.map { |e| e['point_id'] } - 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) 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..3814787 --- /dev/null +++ b/skills/territory-pricing/tests/refresh_data_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +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 +end From 396064e14ecb39b0da5a1054e84e4c8a6e08be1c Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 30 Oct 2026 17:08:51 +0300 Subject: [PATCH 8/9] feat: check the price ladder and date app prices Subscriptions and a lifetime unlock are priced in separate runs, so a re-price could leave yearly above lifetime, or change the lifetime price a day before the subscriptions, without anyone noticing. - add ladder.rb: per territory a longer subscription must cost more in total and less per month, and a lifetime unlock at least twice the cheapest yearly cost (--lifetime-min-years) - block consent in SKILL.md while the ladder has violations - add schedule_body.rb: build the app or IAP price schedule from a proposal, with --start-date so changed territories switch on the same day as the subscriptions - add product.period to plan-input.json for the ladder check - describe the family flow in the playbook - add tests, including the command line tools The dated app and IAP schedule follows Apple's examples and is not verified against the live API. --- skills/territory-pricing/SKILL.md | 35 +++- .../references/asc-pricing-api.md | 54 ++++-- skills/territory-pricing/scripts/ladder.rb | 61 +++++++ .../territory-pricing/scripts/lib/ladder.rb | 71 ++++++++ .../territory-pricing/scripts/lib/schedule.rb | 68 ++++++++ skills/territory-pricing/scripts/plan.rb | 3 +- .../scripts/schedule_body.rb | 39 +++++ skills/territory-pricing/tests/ladder_test.rb | 163 ++++++++++++++++++ .../territory-pricing/tests/schedule_test.rb | 120 +++++++++++++ 9 files changed, 597 insertions(+), 17 deletions(-) create mode 100755 skills/territory-pricing/scripts/ladder.rb create mode 100644 skills/territory-pricing/scripts/lib/ladder.rb create mode 100644 skills/territory-pricing/scripts/lib/schedule.rb create mode 100755 skills/territory-pricing/scripts/schedule_body.rb create mode 100644 skills/territory-pricing/tests/ladder_test.rb create mode 100644 skills/territory-pricing/tests/schedule_test.rb diff --git a/skills/territory-pricing/SKILL.md b/skills/territory-pricing/SKILL.md index 96f63a0..2cd20b2 100644 --- a/skills/territory-pricing/SKILL.md +++ b/skills/territory-pricing/SKILL.md @@ -49,8 +49,12 @@ Continue only on `setup OK`. 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, so a yearly plan never sits above a lifetime unlock -for a day. One kind and one group per run. +**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 @@ -84,7 +88,8 @@ Follow the playbook to collect, per territory: current price and price point, eq 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`, then: +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 @@ -95,6 +100,17 @@ with dates, largest decreases and increases, skipped territories, and the path t 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 @@ -105,12 +121,19 @@ run. To try another strategy, run `plan.rb` again. 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. +* **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 @@ -131,7 +154,9 @@ change that already took effect can only be superseded by a new change, not undo ## 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. +* 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. diff --git a/skills/territory-pricing/references/asc-pricing-api.md b/skills/territory-pricing/references/asc-pricing-api.md index fa639db..c892a7d 100644 --- a/skills/territory-pricing/references/asc-pricing-api.md +++ b/skills/territory-pricing/references/asc-pricing-api.md @@ -52,7 +52,10 @@ Build `plan-input.json` (format in `scripts/plan.rb`'s header): `/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"}` and `"base_territory"` at the top of the file. +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 @@ -82,10 +85,26 @@ it. Reuse `$RUN/points/` when you re-run a plan for the same product on the same Only after consent, always with `--write`; save every request body in the run folder. -**App and in-app purchase: one request.** Include 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. Use temporary ids `${price0}`, `${price1}`, ... and one `included` resource per price: +**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 { @@ -105,10 +124,10 @@ territory with no current price at all has none and falls back to Apple's automa } ``` -For an in-app purchase swap the names: `appPriceSchedules` becomes `inAppPurchasePriceSchedules`, the relationship -`app` is `inAppPurchase` (type `inAppPurchases`), prices are `inAppPurchasePrices`, the price point relationship -is `inAppPurchasePricePoint` (type `inAppPurchasePricePoints`). Never post the base alone: that drops every -per-territory price back to Apple's automatic one. Prices take effect immediately. +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.** @@ -126,11 +145,24 @@ Order: (1) second explicit yes with the irreversibility warning; (2) `startDate` 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`) with `proposal.json`. Report counts and every -mismatch. +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 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..a5ec9c4 --- /dev/null +++ b/skills/territory-pricing/scripts/lib/ladder.rb @@ -0,0 +1,71 @@ +# 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']] } + 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 + + def compare_subscriptions(subs, territory) + subs.select { |p| p['prices'][territory] }.each_cons(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/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/plan.rb b/skills/territory-pricing/scripts/plan.rb index 890300c..1d8a23e 100755 --- a/skills/territory-pricing/scripts/plan.rb +++ b/skills/territory-pricing/scripts/plan.rb @@ -7,9 +7,10 @@ # 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": {...free-form...}, "base_territory": "USA", "territories": [ +# 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' 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/ladder_test.rb b/skills/territory-pricing/tests/ladder_test.rb new file mode 100644 index 0000000..2ba29cd --- /dev/null +++ b/skills/territory-pricing/tests/ladder_test.rb @@ -0,0 +1,163 @@ +# 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 +end 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 From 86479edd121825bf7680e1973b9da6df02014940 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 30 Oct 2026 17:18:43 +0300 Subject: [PATCH 9/9] fix: address the second code review findings A second review found ten defects. Four could produce wrong prices: an untouched skipped row was re-snapped on rebuild, 1.099 was read as 1099, the ladder missed a second plan of the same period, and a target near zero could pick a free price point. - keep untouched CSV cells, skipped rows included, on --rebuild - read an ambiguous three-digit value by the row's price - compare every pair of subscription periods in the ladder - never choose a 0.00 price point; skip a list without a paid one - lock the token and credentials in asc.rb for parallel collection - report an accepted write with a non-JSON body as a success and check the request body before sending anything - refresh each data source on its own in refresh_data.rb - share the tie-break rule, the transient option list and the base territory handling - add a test for each fix --- skills/territory-pricing/scripts/asc.rb | 41 +++++-- .../territory-pricing/scripts/lib/ladder.rb | 6 +- .../territory-pricing/scripts/lib/pricing.rb | 102 ++++++++++++------ skills/territory-pricing/scripts/plan.rb | 4 +- .../territory-pricing/scripts/refresh_data.rb | 26 +++-- skills/territory-pricing/tests/asc_test.rb | 51 +++++++++ skills/territory-pricing/tests/ladder_test.rb | 11 ++ .../territory-pricing/tests/pricing_test.rb | 55 +++++++++- .../tests/refresh_data_test.rb | 19 ++++ 9 files changed, 258 insertions(+), 57 deletions(-) diff --git a/skills/territory-pricing/scripts/asc.rb b/skills/territory-pricing/scripts/asc.rb index ad7ae71..b59a3d1 100755 --- a/skills/territory-pricing/scripts/asc.rb +++ b/skills/territory-pricing/scripts/asc.rb @@ -45,12 +45,15 @@ def detail(raw) 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 ||= resolve_credentials + CREDENTIALS_LOCK.synchronize { @credentials ||= resolve_credentials } end def resolve_credentials @@ -111,12 +114,15 @@ def jwt(creds = credentials, now = Time.now.to_i) end def token - now = Time.now.to_i - if @token.nil? || now > @token_at + 900 - @token = jwt(credentials, now) - @token_at = now + 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 - @token end def enc(value) @@ -145,10 +151,23 @@ def request(method, target, body = nil) end raise HttpError.new(code, res.body) unless code.between?(200, 299) - return res.body.to_s.empty? ? nil : JSON.parse(res.body) + 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}" @@ -194,7 +213,11 @@ def cli(argv) 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') - payload = body && JSON.parse(body.start_with?('@') ? File.read(body[1..-1]) : body) + 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 @@ -207,8 +230,6 @@ def cli(argv) puts "HTTP #{e.code}" puts e.message 1 - rescue JSON::ParserError, Errno::ENOENT => e - usage("bad request body (#{e.class})") rescue Error => e warn "ERROR: #{e.message}" 2 diff --git a/skills/territory-pricing/scripts/lib/ladder.rb b/skills/territory-pricing/scripts/lib/ladder.rb index a5ec9c4..427caac 100644 --- a/skills/territory-pricing/scripts/lib/ladder.rb +++ b/skills/territory-pricing/scripts/lib/ladder.rb @@ -21,7 +21,7 @@ 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']] } + 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| @@ -37,8 +37,10 @@ def check(products, min_lifetime_years: 2.0) 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] }.each_cons(2).flat_map do |short, long| + 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] diff --git a/skills/territory-pricing/scripts/lib/pricing.rb b/skills/territory-pricing/scripts/lib/pricing.rb index 99c4b2a..3663740 100644 --- a/skills/territory-pricing/scripts/lib/pricing.rb +++ b/skills/territory-pricing/scripts/lib/pricing.rb @@ -22,6 +22,9 @@ module Pricing 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 @@ -45,6 +48,10 @@ 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| @@ -96,8 +103,14 @@ def clamp_ratio(ratio, opts) 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) - points.min_by { |p| [(p['price'] - target).abs, p['price']] } + 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 @@ -123,23 +136,27 @@ def build_row(input, strategy, data, opts) return skip(row, 'no equalized price') unless input['equalized'] return skip(row, 'no price points') if (input['points'] || []).empty? - ratio, source = if input['territory'] == opts[:base_territory] - [1.0, 'base'] - else - ratio_for(strategy, input['territory'], data, opts) - end - applied = source == 'base' ? ratio : clamp_ratio(ratio, opts) + 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 = if source == 'base' - [nearest_point(target, input['points']), ''] - else - pick_point(target, input['points'], input['currency'], opts) - end + 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 @@ -156,7 +173,7 @@ def pick_point(target, points, currency, opts) 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? - [nice.min_by { |p| [(p['price'] - target).abs, p['price']] }, ''] + [nearest_point(target, nice), ''] end # Is the price customary for the currency? A currency rule is @@ -238,23 +255,26 @@ def initialize(problems) end end - # '9,90', '$4.99', '1,299.00', '1.299,00', '1 299' -> 1299.0 etc.; nil when - # unreadable or not positive. The last separator is the decimal one, except - # a lone separator with exactly three digits after it ('1,299'): thousands. - def parse_price(raw) + # '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 - value = if last.nil? - s - elsif s[(last + 1)..-1].length == 3 && s.count(',.') == 1 - s.delete(',.') - else - "#{s[0...last].delete(',.')}.#{s[(last + 1)..-1]}" - end - price = Float(value, exception: false) - price&.positive? ? price : nil + 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 @@ -262,18 +282,19 @@ def parse_price(raw) # 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) - edited = CSV.parse(csv_text, headers: true).each_with_object({}) { |r, h| h[r['territory']] = r['proposed'].to_s.strip } - problems = edited.reject { |_, raw| raw.empty? || parse_price(raw) }.map { |t, raw| "#{t}: '#{raw}'" } + 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 = edited[row['territory']] + raw = touched[row['territory']] next row if raw.nil? next keep_current(row, opts) if raw.empty? - value = parse_price(raw) - next row if row['proposed'] && (value - row['proposed']).abs < 0.005 && !skipped?(row) - + 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 @@ -282,6 +303,23 @@ def apply_csv_edits(rows, csv_text, points_by_territory, 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? diff --git a/skills/territory-pricing/scripts/plan.rb b/skills/territory-pricing/scripts/plan.rb index 1d8a23e..ea7fe6b 100755 --- a/skills/territory-pricing/scripts/plan.rb +++ b/skills/territory-pricing/scripts/plan.rb @@ -85,7 +85,7 @@ def print_summary(meta, rows) e.problems.each { |line| warn " #{line}" } exit 2 end - proposal['meta']['options'] = popts.reject { |k, _| %i[band_ratios tier_ratios endings_rules].include?(k) } + 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']) @@ -123,7 +123,7 @@ def print_summary(meta, rows) 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' => popts.reject { |k, _| %i[band_ratios tier_ratios endings_rules].include?(k) } } + '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)) diff --git a/skills/territory-pricing/scripts/refresh_data.rb b/skills/territory-pricing/scripts/refresh_data.rb index 4b141c5..f2480c1 100755 --- a/skills/territory-pricing/scripts/refresh_data.rb +++ b/skills/territory-pricing/scripts/refresh_data.rb @@ -108,6 +108,21 @@ def country_codes .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] @@ -116,13 +131,4 @@ def write_snapshot(name, values, as_of) end end -if $PROGRAM_NAME == __FILE__ - %i[big_mac ppp gni].each do |name| - values, meta = DataSources.send("fetch_#{name}") - DataSources.write_snapshot(name, values, meta['as_of']) - puts "#{name}: #{values.size} countries, as of #{meta['as_of']}" - rescue StandardError => e - warn "#{name}: FAILED (#{e.class}: #{e.message}); snapshot left unchanged" - exit 1 - end -end +exit(DataSources.refresh_all.empty? ? 0 : 1) if $PROGRAM_NAME == __FILE__ diff --git a/skills/territory-pricing/tests/asc_test.rb b/skills/territory-pricing/tests/asc_test.rb index e940b46..2c22543 100644 --- a/skills/territory-pricing/tests/asc_test.rb +++ b/skills/territory-pricing/tests/asc_test.rb @@ -82,4 +82,55 @@ 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/ladder_test.rb b/skills/territory-pricing/tests/ladder_test.rb index 2ba29cd..ce052f8 100644 --- a/skills/territory-pricing/tests/ladder_test.rb +++ b/skills/territory-pricing/tests/ladder_test.rb @@ -160,4 +160,15 @@ def test_cli_names_a_proposal_without_a_period 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/pricing_test.rb b/skills/territory-pricing/tests/pricing_test.rb index d19b69e..89e8359 100644 --- a/skills/territory-pricing/tests/pricing_test.rb +++ b/skills/territory-pricing/tests/pricing_test.rb @@ -320,5 +320,58 @@ def test_shipped_rules_match_local_conventions 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 -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 index 3814787..47563ee 100644 --- a/skills/territory-pricing/tests/refresh_data_test.rb +++ b/skills/territory-pricing/tests/refresh_data_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative 'test_helper' +require 'stringio' require_relative '../scripts/refresh_data' # refresh_data.rb with the network stubbed. @@ -41,4 +42,22 @@ 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