From b0641c1fc8215361e590753bf9c583c0fe75d251 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 23 Oct 2026 16:52:28 +0800 Subject: [PATCH] play products: clamp regional prices to Play's min/max, per-product results; apple iap: skip live localizations, per-product results --- CHANGELOG.md | 3 + cli/apple/iap.ts | 36 +++++-- cli/apple/subscriptions.ts | 30 ++++-- cli/data/flags.json | 34 +++---- cli/lib/args.ts | 2 +- cli/lib/loc-lock.ts | 16 +++ cli/lib/play-limits.ts | 78 +++++++++++++++ cli/play/products.ts | 109 ++++++++++++++------ cli/play/subscriptions.ts | 27 ++++- skills/store-pitfalls/SKILL.md | 27 +++++ tests/helpers/fake-stores.ts | 26 ++++- tests/price-limits-locked-loc.test.ts | 137 ++++++++++++++++++++++++++ 12 files changed, 456 insertions(+), 69 deletions(-) create mode 100644 cli/lib/loc-lock.ts create mode 100644 cli/lib/play-limits.ts create mode 100644 tests/price-limits-locked-loc.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4796754..7625693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased +- `play products` / `play subscriptions`: regions outside Play's min / max (a 649.99 USD product in KR: "must be between ₩70 and ₩600,000") are flagged in the dry run (old -> new) and clamped to the bound on write with a warning; `--skip-out-of-range-regions` leaves them out. Limits Play reports in a 400 are learned (`.storebound/play-price-limits.json`) and the write is retried. A product or subscription that fails no longer stops the others: per-product results, exit 1 at the end. +- `apple iap` / `apple subscriptions`: edits to approved (ACTIVE) localizations, which App Store Connect refuses with 409 UNMODIFIABLE, are marked "not editable now" in the dry run and skipped with a warning on write; prices and the other changes still apply. One IAP failing no longer stops the rest (results at the end, exit 1). + ## 0.8.46 · 2026-10-23 - `storebound watch` has an `ingest` sink (`--notify ingest`, `ingest` in ~/.storebound/watch.json): a JSON POST per event to a push ingest endpoint with a Bearer token read from a file at send time (refused when group or world readable), 8 KiB body cap, 60 sends a minute, one retry on 5xx; `watch test` covers it. diff --git a/cli/apple/iap.ts b/cli/apple/iap.ts index 39cc4f7..2b684a9 100644 --- a/cli/apple/iap.ts +++ b/cli/apple/iap.ts @@ -13,7 +13,8 @@ import { APPLE_IAP_LIMITS } from '../lib/copy'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { dryPlan, type PlanRow } from '../lib/dry'; -import { done, log, step } from '../lib/log'; +import { isLockedLocState, isUnmodifiableError, lockedLocNote } from '../lib/loc-lock'; +import { done, log, step, warn } from '../lib/log'; import { LAG_HINT, reportMissing } from '../lib/store-lag'; import { appleTerritory, nearestPrices, samePrice } from '../lib/money'; import { createOnce, findOrCreate, getById } from '../lib/upsert'; @@ -27,8 +28,9 @@ export const APPLE_IAP_TYPE = { consumable: 'CONSUMABLE', non_consumable: 'NON_C // one plan line per change: store texts carry line breaks, which broke the dry-run table const clip = (s: string, n = 40): string => { const a = [...s.replace(/\s+/g, ' ').trim()]; return a.length > n ? `${a.slice(0, n - 1).join('')}…` : a.join(''); }; -export type IapLocalization = { locale: string; id?: string; name: string; description: string }; -export type IapLocalizationChange = { locale: string; id: string; attrs: { name?: string; description?: string }; note: string }; +export type IapLocalization = { locale: string; id?: string; name: string; description: string; state?: string }; +/** `locked`: the localization is live (APPROVED / ACTIVE) and Apple refuses the edit; the note says so. */ +export type IapLocalizationChange = { locale: string; id: string; attrs: { name?: string; description?: string }; note: string; locked?: string }; /** Existing locales whose name/description differ from the config: the attributes to PATCH and a short plan note. */ export function iapLocalizationChanges(store: IapLocalization[], wanted: IapLocalization[]): IapLocalizationChange[] { const out: IapLocalizationChange[] = []; @@ -39,7 +41,9 @@ export function iapLocalizationChanges(store: IapLocalization[], wanted: IapLoca const parts: string[] = []; if ((s.name ?? '') !== w.name) { attrs.name = w.name; parts.push(`name "${clip(s.name ?? '')}" -> "${clip(w.name)}"`); } if ((s.description ?? '') !== w.description) { attrs.description = w.description; parts.push(`description "${clip(s.description ?? '')}" -> "${clip(w.description)}"`); } - if (parts.length) out.push({ locale: w.locale, id: s.id, attrs, note: `localization ${w.locale}: ${parts.join(', ')}` }); + if (!parts.length) continue; + const locked = isLockedLocState(s.state) ? s.state : undefined; + out.push({ locale: w.locale, id: s.id, attrs, note: `localization ${w.locale}: ${locked ? `${lockedLocNote(locked)}: ` : ''}${parts.join(', ')}`, ...(locked ? { locked } : {}) }); } return out; } @@ -94,7 +98,7 @@ export async function run(ctx: Ctx): Promise { const live = (await appleCurrent(c, id, p.id, false)).rows.filter(x => !x.startDate || x.startDate <= new Date().toISOString().slice(0, 10)); return wanted(p).flatMap(w => { const cur = live.find(x => x.territory === w.territory); return cur && !samePrice(cur.price, w.price) ? [`${w.territory} ${cur.price} -> ${w.price}`] : []; }); } - const locOf = (x: J) => ({ locale: x.attributes.locale as string, name: (x.attributes.name ?? '') as string, description: (x.attributes.description ?? '') as string }); + const locOf = (x: J) => ({ locale: x.attributes.locale as string, name: (x.attributes.name ?? '') as string, description: (x.attributes.description ?? '') as string, state: x.attributes.state as string | undefined }); const wantedLocs = (p: (typeof products)[number]) => Object.entries(p.listings).flatMap(([k, l]) => { const locale = langs.get(k)?.apple; return locale ? [{ locale, name: l.title, description: l.description }] : []; }); const wantedAttrs = (p: (typeof products)[number]): IapAttributes => ({ name: p.referenceName ?? Object.values(p.listings)[0]!.title, reviewNote: p.reviewNote, familySharable: p.familySharable }); /** The store's availability as territory ids + the new-territories flag (null when there is none). */ @@ -117,11 +121,14 @@ export async function run(ctx: Ctx): Promise { const allowPrices = ctx.args.has('allow-price-changes'); const rows: PlanRow[] = []; const created: string[] = []; + const results: { id: string; ok: boolean; text: string }[] = []; + let skippedLocs = 0; for (const [i, p] of products.entries()) { const tag = step(i + 1, products.length, p.id); const lookup = async (): Promise => (await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`)).data ?.find((x: J) => x.attributes?.productId === p.id) ?? null; if (dry) { const f = await lookup(); rows.push(await planProduct(p, f?.id, f?.attributes?.state)); continue; } + try { // product ids are unique per app: a 409 finds the existing one; the journal covers the list lag after a create const first = Object.values(p.listings)[0]!; const made = await findOrCreate({ what: `in-app purchase ${p.id}`, tag: 'apple', unique: true, find: lookup, @@ -148,10 +155,14 @@ export async function run(ctx: Ctx): Promise { log('apple', ` localization ${locale}`); } for (const ch of iapLocalizationChanges(storeLocs.map((x: J) => ({ id: x.id, ...locOf(x) })), wantedLocs(p))) { + if (ch.locked) { warn('apple', ` ${ch.note}; skipped`); skippedLocs++; continue; } const l = Object.entries(p.listings).find(([k]) => langs.get(k)?.apple === ch.locale)![1]; if ([...l.description].length > APPLE_IAP_LIMITS.description) throw new Error(`${p.id} ${ch.locale}: description over ${APPLE_IAP_LIMITS.description} characters (Apple limit)`); if ([...l.title].length > APPLE_IAP_LIMITS.name) throw new Error(`${p.id} ${ch.locale}: name over ${APPLE_IAP_LIMITS.name} characters (Apple limit)`); - await c.asc('PATCH', `/v1/inAppPurchaseLocalizations/${ch.id}`, { data: { type: 'inAppPurchaseLocalizations', id: ch.id, attributes: ch.attrs } }); + try { await c.asc('PATCH', `/v1/inAppPurchaseLocalizations/${ch.id}`, { data: { type: 'inAppPurchaseLocalizations', id: ch.id, attributes: ch.attrs } }); } catch (e) { + if (!isUnmodifiableError(e)) throw e; + warn('apple', ` localization ${ch.locale}: ${lockedLocNote('ACTIVE')}; skipped`); skippedLocs++; continue; + } log('apple', ` ${ch.note}`); } if (!made.created) { @@ -183,6 +194,12 @@ export async function run(ctx: Ctx): Promise { availableTerritories: { data: territories.map(t => ({ type: 'territories', id: t })) } } } }); log('apple', ` ${avail?.data ? availDiff.note : `available in ${territories.length} territories + new ones`}`); } + results.push({ id: p.id, ok: true, text: 'ok' }); + } catch (e) { + const msg = (e as Error).message.replace(/\s+/g, ' ').slice(0, 300); + warn('apple', `${tag}: FAILED: ${msg}; going on with the next product`); + results.push({ id: p.id, ok: false, text: msg }); + } } if (dry) { console.log(dryPlan('apple iap', rows)); @@ -196,6 +213,13 @@ export async function run(ctx: Ctx): Promise { const listed = (await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)).map((x: J) => x.attributes.productId as string); if (reportMissing('apple', 'new products', created, listed, { fail: false }).length) log('apple', 'wait a minute before apple iap-review, or it cannot see them'); } + const failed = results.filter(r => !r.ok); + if (skippedLocs) warn('apple', `${skippedLocs} localization edit(s) skipped: the localization is live (APPROVED / ACTIVE). The text changes with the next review of the product, or add a new localization.`); + if (failed.length) { + process.exitCode = 1; + warn('apple', `${failed.length} of ${results.length} product(s) failed (${failed.map(f => f.id).join(', ')}): ${failed.map(f => `${f.id}: ${f.text}`).join(' | ')}. The others are written. Fix the cause, run \`storebound apple iap --dry\`, then again with --yes.`); + return; + } done('apple', 'iap done (next: apple iap-review for the review screenshots)'); /** What a real run would do to one product, from read-only calls. */ diff --git a/cli/apple/subscriptions.ts b/cli/apple/subscriptions.ts index d892670..9ac596c 100644 --- a/cli/apple/subscriptions.ts +++ b/cli/apple/subscriptions.ts @@ -18,6 +18,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { type OfferCodeDef, sellsOn, type Subscription, type SubscriptionGroup } from '../lib/config'; import type { Ctx } from '../lib/ctx'; +import { isLockedLocState, isUnmodifiableError, lockedLocNote } from '../lib/loc-lock'; import { confirm, need } from '../lib/ctx'; import { done, log, step, warn } from '../lib/log'; import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges, withoutYes } from '../lib/price-gate'; @@ -73,7 +74,7 @@ const heldOf = (id: string, changes: { territory: string; from: number | null; t /** Everything an existing subscription has that differs from the config (pure data; the run prints and applies it). */ export interface SubDrift { attrs: AppleAttrDiff; - locs: { id: string; loc: string; patch: Record }[]; + locs: { id: string; loc: string; patch: Record; locked?: string }[]; availability: { missing: string[]; newTerritories: boolean } | null; intro: string[]; } @@ -88,7 +89,7 @@ export async function readSubDrift(c: Asc, s: Subscription, id: string, locale: const loc = locale.get(lang); const h = loc ? haveLocs.find((x: J) => x.attributes.locale === loc) : undefined; const patch = h ? appleLocDiff(l, { name: h.attributes.name, description: h.attributes.description }) : null; - if (h && patch && loc) locs.push({ id: h.id, loc, patch }); + if (h && patch && loc) locs.push({ id: h.id, loc, patch, ...(isLockedLocState(h.attributes.state) ? { locked: h.attributes.state as string } : {}) }); } let availability: SubDrift['availability'] = null; const av = (await c.maybe(`/v1/subscriptions/${id}/subscriptionAvailability`))?.data; @@ -103,14 +104,14 @@ export async function readSubDrift(c: Asc, s: Subscription, id: string, locale: } /** Group localizations whose name / customAppName differ from the config. Read-only. */ -export async function readGroupLocDrift(c: Asc, g: SubscriptionGroup, groupId: string, locale: Map): Promise<{ id: string; loc: string; patch: Record }[]> { +export async function readGroupLocDrift(c: Asc, g: SubscriptionGroup, groupId: string, locale: Map): Promise<{ id: string; loc: string; patch: Record; locked?: string }[]> { const have = await c.all(`/v1/subscriptionGroups/${groupId}/subscriptionGroupLocalizations?limit=200`); - const out: { id: string; loc: string; patch: Record }[] = []; + const out: { id: string; loc: string; patch: Record; locked?: string }[] = []; for (const [lang, l] of Object.entries(g.listings)) { const loc = locale.get(lang); const h = loc ? have.find((x: J) => x.attributes.locale === loc) : undefined; const patch = h ? appleGroupLocDiff(l, { name: h.attributes.name, customAppName: h.attributes.customAppName }) : null; - if (h && patch && loc) out.push({ id: h.id, loc, patch }); + if (h && patch && loc) out.push({ id: h.id, loc, patch, ...(isLockedLocState(h.attributes.state) ? { locked: h.attributes.state as string } : {}) }); } return out; } @@ -120,7 +121,7 @@ export function driftLines(id: string, d: SubDrift): string[] { const av = d.availability; return [ ...d.attrs.text.map(x => `${id}: ${x}`), ...d.attrs.report, - ...d.locs.map(l => `${id} ${l.loc}: ${Object.keys(l.patch).join(' + ')} differ from the config`), + ...d.locs.map(l => `${id} ${l.loc}: ${Object.keys(l.patch).join(' + ')} differ from the config${l.locked ? ` - ${lockedLocNote(l.locked)}` : ''}`), ...(av ? [`${id}: availability${av.missing.length ? ` ${av.missing.length} territories missing (${av.missing.slice(0, 5).join(', ')})` : ''}${av.newTerritories ? ' new territories off' : ''}`] : []), ...d.intro, ]; @@ -160,7 +161,7 @@ export async function run(ctx: Ctx): Promise { const heldDry: HeldPrice[] = []; for (const g of groups) { const sg = state.groups.find(x => x.referenceName === g.name); - if (sg) for (const d of await readGroupLocDrift(c, g, sg.id, locale)) log(TAG, `group "${g.name}" ${d.loc}: ${Object.keys(d.patch).join(' + ')} differ from the config`); + if (sg) for (const d of await readGroupLocDrift(c, g, sg.id, locale)) log(TAG, `group "${g.name}" ${d.loc}: ${Object.keys(d.patch).join(' + ')} differ from the config${d.locked ? ` - ${lockedLocNote(d.locked)}` : ''}`); for (const s of g.subscriptions) { const cur = state.subscriptions.find(x => x.productId === s.id); if (!cur) continue; @@ -209,7 +210,11 @@ export async function run(ctx: Ctx): Promise { } if (state.groups.some(x => x.id === groupId)) { for (const d of await readGroupLocDrift(c, g, groupId, locale)) { - await c.asc('PATCH', `/v1/subscriptionGroupLocalizations/${d.id}`, appleGroupLocPatchBody(d.id, d.patch)); + if (d.locked) { warn(TAG, ` group ${d.loc}: ${lockedLocNote(d.locked)}; skipped`); continue; } + try { await c.asc('PATCH', `/v1/subscriptionGroupLocalizations/${d.id}`, appleGroupLocPatchBody(d.id, d.patch)); } catch (e) { + if (!isUnmodifiableError(e)) throw e; + warn(TAG, ` group ${d.loc}: ${lockedLocNote('ACTIVE')}; skipped`); continue; + } log(TAG, ` group ${d.loc}: ${Object.keys(d.patch).join(' + ')} updated`); } } @@ -242,7 +247,14 @@ export async function run(ctx: Ctx): Promise { // and the period / family sharing are only reported (Apple cannot change them) const d = await readSubDrift(c, s, subId, locale, territories); if (Object.keys(d.attrs.patch).length) { await c.asc('PATCH', `/v1/subscriptions/${subId}`, appleSubPatchBody(subId, d.attrs.patch)); log(TAG, ` attributes updated: ${d.attrs.text.join(', ')}`); } - for (const l of d.locs) { await c.asc('PATCH', `/v1/subscriptionLocalizations/${l.id}`, appleLocPatchBody(l.id, l.patch)); log(TAG, ` ${l.loc}: ${Object.keys(l.patch).join(' + ')} updated`); } + for (const l of d.locs) { + if (l.locked) { warn(TAG, ` ${l.loc}: ${lockedLocNote(l.locked)}; skipped`); continue; } + try { await c.asc('PATCH', `/v1/subscriptionLocalizations/${l.id}`, appleLocPatchBody(l.id, l.patch)); } catch (e) { + if (!isUnmodifiableError(e)) throw e; + warn(TAG, ` ${l.loc}: ${lockedLocNote('ACTIVE')}; skipped`); continue; + } + log(TAG, ` ${l.loc}: ${Object.keys(l.patch).join(' + ')} updated`); + } if (d.availability) { await c.asc('POST', '/v1/subscriptionAvailabilities', appleAvailabilityBody(subId, territories)); log(TAG, ` availability set again: ${territories.length} territories + new ones`); } for (const x of [...d.attrs.report, ...d.intro]) warn(TAG, x); } diff --git a/cli/data/flags.json b/cli/data/flags.json index 9b96343..b19831e 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -133,6 +133,7 @@ "only-decreases", "out", "reconvert", + "skip-out-of-range-regions", "source", "store", "threshold", @@ -174,6 +175,7 @@ "only-decreases", "out", "reconvert", + "skip-out-of-range-regions", "source", "store", "threshold", @@ -293,6 +295,7 @@ "only-decreases", "out", "reconvert", + "skip-out-of-range-regions", "source", "store", "threshold", @@ -356,6 +359,7 @@ "only-decreases", "out", "reconvert", + "skip-out-of-range-regions", "source", "store", "threshold", @@ -416,6 +420,7 @@ "only-decreases", "out", "reconvert", + "skip-out-of-range-regions", "source", "store", "threshold", @@ -624,6 +629,7 @@ "play", "react-native", "reconvert", + "skip-out-of-range-regions", "source", "store", "threshold", @@ -705,7 +711,8 @@ ], "play next-version-code": [], "play products": [ - "allow-price-changes" + "allow-price-changes", + "skip-out-of-range-regions" ], "play release": [ "alpha", @@ -740,7 +747,8 @@ ], "play status": [ "allow-price-changes", - "review-sent" + "review-sent", + "skip-out-of-range-regions" ], "play subscriptions": [ "all", @@ -762,6 +770,7 @@ "out", "prune-languages", "reconvert", + "skip-out-of-range-regions", "source", "store", "threshold", @@ -803,6 +812,7 @@ "only-decreases", "out", "reconvert", + "skip-out-of-range-regions", "source", "store", "threshold", @@ -1026,6 +1036,7 @@ "react-native", "repair-stuck", "signature", + "skip-out-of-range-regions", "src", "unity", "variant", @@ -1263,6 +1274,7 @@ "signature", "since", "size", + "skip-out-of-range-regions", "sku", "source", "src", @@ -1391,9 +1403,6 @@ "apple game-center": [ "storebound apple game-center [status] [--dry] [--yes] [--json]" ], - "apple iap": [ - "storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]" - ], "apple iap-review": [ "storebound apple iap-review [--dry]" ], @@ -1407,9 +1416,6 @@ "apple next-build": [ "storebound apple next-build -> 42" ], - "apple offer-codes": [ - "storebound apple offer-codes [--only id1,id2] [--codes] [--dry] [--yes] [--json]" - ], "apple ppo": [ "storebound apple ppo status [--json]", "storebound apple ppo create --variant b[,c] [--name \"...\"] [--traffic 30] [--dry]", @@ -1434,9 +1440,6 @@ "apple resubmit": [ "storebound apple resubmit --build [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]" ], - "apple review-details": [ - "storebound apple review-details [--contact-from ] [--dry]" - ], "apple screenshots": [ "storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]", "storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)" @@ -1453,9 +1456,6 @@ "apple submit": [ "storebound apple submit --build [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]" ], - "apple subscriptions": [ - "storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes] [--allow-price-changes]" - ], "apple testflight": [ "storebound apple testflight status [--json] groups, testers per group, recent builds + beta states", "storebound apple testflight groups list the groups and what the config would create (read-only)", @@ -1596,9 +1596,6 @@ "play next-version-code": [ "storebound play next-version-code -> 43" ], - "play products": [ - "storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes]" - ], "play release": [ "storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--dry]", "storebound play release --version-code 3 --production draft", @@ -1613,9 +1610,6 @@ "play status": [ "storebound play status [--json] [--review-sent]" ], - "play subscriptions": [ - "storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]" - ], "play upload": [ "storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]" ], diff --git a/cli/lib/args.ts b/cli/lib/args.ts index 5e68b04..780121c 100644 --- a/cli/lib/args.ts +++ b/cli/lib/args.ts @@ -20,7 +20,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args { const repeated = new Map(); const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); }; const _: string[] = []; - const bool = new Set(['dry', 'yes', 'allow-price-changes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', 'not-sent-for-review', 'review-sent', 'once', ...booleans]); + const bool = new Set(['dry', 'yes', 'allow-price-changes', 'skip-out-of-range-regions', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', 'not-sent-for-review', 'review-sent', 'once', ...booleans]); for (let i = 0; i < argv.length; i++) { const a = argv[i]!; if (!a.startsWith('--')) { _.push(a); continue; } diff --git a/cli/lib/loc-lock.ts b/cli/lib/loc-lock.ts new file mode 100644 index 0000000..b0470a7 --- /dev/null +++ b/cli/lib/loc-lock.ts @@ -0,0 +1,16 @@ +// App Store Connect refuses to edit a localization that is already live: PATCH /v1/inAppPurchaseLocalizations/ (and +// subscriptionLocalizations, subscriptionGroupLocalizations) answers 409 ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE +// "Cannot edit InAppPurchaseLocalization when it is in ACTIVE state" once the product was approved. The text then +// changes only with the next review of the product (or a new localization). The localization's `state` is read, the +// dry run marks such edits "not editable now" and the write skips them with a warning; a 409 of this kind that still +// comes (a state we did not know) is skipped the same way instead of ending the run. Pure. + +/** Localization states that cannot be edited (Apple's own error names ACTIVE; the API lists APPROVED). */ +const LOCKED = new Set(['ACTIVE', 'APPROVED']); + +export const isLockedLocState = (state: unknown): boolean => typeof state === 'string' && LOCKED.has(state.toUpperCase()); + +export const lockedLocNote = (state: string): string => `not editable now (state ${state}; changes ship with the next review of the product / needs a new localization)`; + +/** True for Apple's 409 UNMODIFIABLE answer to an edit of a live resource. */ +export const isUnmodifiableError = (e: unknown): boolean => /UNMODIFIABLE|Cannot edit \w+ when it is in \w+ state/i.test(e instanceof Error ? e.message : String(e)); diff --git a/cli/lib/play-limits.ts b/cli/lib/play-limits.ts new file mode 100644 index 0000000..b409637 --- /dev/null +++ b/cli/lib/play-limits.ts @@ -0,0 +1,78 @@ +// Google Play refuses a regional price outside the region's min / max ("Price for KR must be between ₩70 and ₩600,000, +// found ₩950,000"). convertRegionPrices converts a USD base per region and does not look at those limits, so a high USD +// price (649.99) breaks the PATCH for a few regions and the run dies half way. The API does not list the limits, so: +// a small seed table, every limit learned from a 400 (kept in /.storebound/play-price-limits.json so the next +// dry run and write know it), and a clamp: by default the price is lowered (or raised) to the region's bound with a +// warning; --skip-out-of-range-regions leaves the region out instead. Pure except load / save of the cache. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fromMoney, type Money, toMoney } from './money'; + +export interface RegionLimit { currency: string; min: number; max: number } +export type RegionLimits = Record; + +/** Limits seen on live Play (extended by every 400 the run learns from). */ +export const SEED_LIMITS: RegionLimits = { KR: { currency: 'KRW', min: 70, max: 600000 } }; + +export interface LimitError { region: string; min: number; max: number; found: number } + +/** "₩600,000" / "$1.99" / "1.234,50 €" -> number. Commas followed by exactly three digits are thousands separators. */ +export function parseLocalAmount(s: string): number { + let t = s.replace(/[^\d.,]/g, ''); + t = /,\d{3}(?!\d)/.test(t) ? t.replace(/,(?=\d{3}(?!\d))/g, '') : t.replace(',', '.'); + return Number(t); +} + +/** The region and bounds from Play's 400 text, or null when it is another error. */ +export function parseLimitError(message: string): LimitError | null { + const m = /Price for ([A-Z]{2}) must be between (\S+) and (\S+?),? found (\S+?)\.?(?:["\s]|$)/.exec(message); + if (!m) return null; + const [min, max, found] = [parseLocalAmount(m[2]!), parseLocalAmount(m[3]!), parseLocalAmount(m[4]!)]; + return [min, max, found].every(Number.isFinite) && max >= min ? { region: m[1]!, min, max, found } : null; +} + +const cacheFile = (root: string): string => join(root, '.storebound', 'play-price-limits.json'); + +export function loadLimits(root: string): RegionLimits { + let learned: RegionLimits = {}; + try { if (existsSync(cacheFile(root))) learned = JSON.parse(readFileSync(cacheFile(root), 'utf8')) as RegionLimits; } catch { /* unreadable cache: seed only */ } + return { ...SEED_LIMITS, ...learned }; +} + +export function saveLimits(root: string, limits: RegionLimits): void { + try { + mkdirSync(dirname(cacheFile(root)), { recursive: true }); + writeFileSync(cacheFile(root), JSON.stringify(limits, null, 2)); + } catch { /* the cache is a convenience */ } +} + +export interface Clamp { region: string; currency: string; from: number; to: number | null; bound: 'max' | 'min' } +export const clampText = (c: Clamp): string => `${c.region} ${c.currency} ${c.from} -> ${c.to === null ? 'region left out' : c.to} (${c.bound === 'max' ? 'above' : 'below'} Play's ${c.bound} for the region)`; + +/** + * Regions outside their min / max: clamped to the bound, or removed with skip. Regions without a known limit, or priced + * in another currency than the limit, are untouched. Returns new rows; the input is not changed. + */ +export function applyLimits(regions: T[], limits: RegionLimits, skip = false): { regions: T[]; clamps: Clamp[] } { + const out: T[] = []; + const clamps: Clamp[] = []; + for (const r of regions) { + const l = limits[r.regionCode]; + if (!l || l.currency !== r.price.currencyCode) { out.push(r); continue; } + const v = fromMoney(r.price); + const to = v > l.max ? l.max : v < l.min ? l.min : null; + if (to === null) { out.push(r); continue; } + const bound = to === l.max ? 'max' : 'min'; + clamps.push({ region: r.regionCode, currency: l.currency, from: v, to: skip ? null : to, bound }); + if (!skip) out.push({ ...r, price: toMoney(r.price.currencyCode, to) }); + } + return { regions: out, clamps }; +} + +/** Learn a limit from Play's 400 for a region priced in `currency`; true when it is new or different. */ +export function learnLimit(limits: RegionLimits, e: LimitError, currency: string): boolean { + const cur = limits[e.region]; + if (cur && cur.currency === currency && cur.min === e.min && cur.max === e.max) return false; + limits[e.region] = { currency, min: e.min, max: e.max }; + return true; +} diff --git a/cli/play/products.ts b/cli/play/products.ts index c4607bc..02c22e9 100644 --- a/cli/play/products.ts +++ b/cli/play/products.ts @@ -14,6 +14,11 @@ // a "price changes on existing products (held)" section. (lib/price-gate.ts) // Play has no non-renewing type: a `non_renewing` product is a plain one-time product here (your app consumes it // when the access ends). Products with `platforms` that leave out android are skipped. +// Regional price limits: Play refuses a price outside a region's min / max (a 649.99 USD product in KR: "must be between +// ₩70 and ₩600,000"). The dry run lists those regions (old -> new); a write clamps them to the bound with a warning, or +// leaves them out with --skip-out-of-range-regions. Limits Play reports in a 400 are learned, kept in +// .storebound/play-price-limits.json and the write retried. A product that fails does not stop the others: every +// product gets a result line, and the run exits 1 at the end when any failed (lib/play-limits.ts). // --dry ends with the standard plan table (lib/dry.ts). After a real run the product list is read again: a product // created a moment ago can be missing from it for a minute, and that gap is named (docs/pitfalls.md). import { playCodes, sellsOn } from '../lib/config'; @@ -24,6 +29,7 @@ import { done, log, step, warn } from '../lib/log'; import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges } from '../lib/price-gate'; import { LAG_HINT, reportMissing } from '../lib/store-lag'; import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money'; +import { applyLimits, type Clamp, clampText, learnLimit, loadLimits, parseLimitError, saveLimits } from '../lib/play-limits'; import { playClient } from './client'; const OPTION = 'buy'; @@ -107,36 +113,44 @@ export async function run(ctx: Ctx): Promise { const play = await playClient(root, pkg); const existing = new Map((await listOneTimeProducts(play)).map(p => [p.productId, p])); const langs = new Map(config.languages.map(l => [l.id, l])); + const limits = loadLimits(root); + const skipOut = ctx.args.has('skip-out-of-range-regions'); - const plan: { p: (typeof products)[number]; tag: string; body: Record; version: string; cur?: Existing; changes: ProductChange[]; regions: number; listings: number }[] = []; + const plan: { p: (typeof products)[number]; tag: string; body: Record; version: string; cur?: Existing; changes: ProductChange[]; regions: number; listings: number; + all: RegionConfig[]; clamps: Clamp[]; mkBody: (regions: RegionConfig[]) => Record }[] = []; for (const [i, p] of products.entries()) { const tag = step(i + 1, products.length, p.id); const conv = await play.call('POST', `${play.API}/pricing:convertRegionPrices`, { price: toMoney('USD', p.usd) }); if (!conv.convertedRegionPrices || !conv.convertedOtherRegionsPrice) throw new Error(`${p.id}: convertRegionPrices returned no prices`); - const regions = playRegionConfigs(conv.convertedRegionPrices, p.overrides); + const all = playRegionConfigs(conv.convertedRegionPrices, p.overrides); + const lim = applyLimits(all, limits, skipOut); + const regions = lim.regions; const listings = Object.entries(p.listings).flatMap(([id, l]) => playCodes(langs.get(id)!).map(languageCode => ({ languageCode, title: l.title, description: l.description }))); if (!listings.length) throw new Error(`${p.id}: no listing language has a Play code`); - const body = { + const mkBody = (rs: RegionConfig[]): Record => ({ packageName: pkg, productId: p.id, listings, purchaseOptions: [{ purchaseOptionId: OPTION, buyOption: { legacyCompatible: true, multiQuantityEnabled: false }, - regionalPricingAndAvailabilityConfigs: regions, - newRegionsConfig: { usdPrice: conv.convertedOtherRegionsPrice.usdPrice, eurPrice: conv.convertedOtherRegionsPrice.eurPrice, availability: 'AVAILABLE' }, + regionalPricingAndAvailabilityConfigs: rs, + newRegionsConfig: { usdPrice: conv.convertedOtherRegionsPrice!.usdPrice, eurPrice: conv.convertedOtherRegionsPrice!.eurPrice, availability: 'AVAILABLE' }, }], - }; + }); + const body = mkBody(regions); const cur = existing.get(p.id); const changes = productChanges(cur, { listings, regions, newRegions: conv.convertedOtherRegionsPrice }); const what = !changes.length ? 'unchanged' : !cur ? `${dry ? 'would create' : 'create'} · ${regions.length} regions · ${listings.length} listings` : `${dry ? 'would update' : 'update'} ${changes.map(c => c.text).join('; ')}`; log('play', `${tag}: ${what}${dry ? ' (dry)' : ''}`); - plan.push({ p, tag, body, version: conv.regionVersion?.version ?? '2022/02', cur, changes, regions: regions.length, listings: listings.length }); + for (const c of lim.clamps) warn('play', `${tag}: price limit ${clampText(c)}${skipOut ? '' : ' (clamped; --skip-out-of-range-regions leaves such regions out)'}`); + plan.push({ p, tag, body, version: conv.regionVersion?.version ?? '2022/02', cur, changes, regions: regions.length, listings: listings.length, all, clamps: lim.clamps, mkBody }); } if (dry) { console.log(dryPlan('play products', plan.map(x => !x.changes.length ? { item: x.p.id, action: 'keep' } : !x.cur ? { item: x.p.id, action: 'create', detail: `${x.regions} regions, ${x.listings} listings, USD ${x.p.usd}` } : { item: x.p.id, action: x.changes.some(c => c.owner) ? 'update (owner: price change, --allow-price-changes)' : 'update', detail: x.changes.map(c => c.text).join('; ') }))); + for (const x of plan.filter(y => y.clamps.length && y.changes.length)) console.log(`price limits on ${x.p.id} (USD ${x.p.usd}): ${x.clamps.map(clampText).join('; ')}${skipOut ? '' : ' -> written clamped to the bound'}`); const h = heldSection(plan.filter(x => x.cur && x.changes.some(c => c.owner)).map(x => ({ id: x.p.id, changes: x.changes.flatMap(c => c.prices ?? []) })), ctx.args.has('allow-price-changes')); if (h) console.log(h); const absent = plan.filter(x => !x.cur).map(x => x.p.id); @@ -156,33 +170,68 @@ export async function run(ctx: Ctx): Promise { if (!activateOk) warn('play', `activation of ${activate.length} existing product(s) skipped: confirm with --yes or in a terminal`); const held: HeldPrice[] = []; - for (const x of plan) { - if (!x.changes.length) continue; - const id = encodeURIComponent(x.p.id); - const v = encodeURIComponent(x.version); - const ownerChange = !!x.cur && x.changes.some(c => c.owner); - if (x.cur && ownerChange && !priceOk) { - held.push({ id: x.p.id, changes: x.changes.flatMap(c => c.prices ?? []) }); - if (x.changes.some(c => c.text.startsWith('listings '))) { - await play.call('PATCH', `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`, x.body); - log('play', `${x.tag}: listings updated (prices unchanged)`); + const results: { id: string; ok: boolean; text: string }[] = []; + /** PATCH; a 400 naming a region's min / max is learned, the region clamped (or left out) and the PATCH sent again. */ + async function patch(x: (typeof plan)[number], url: string): Promise { + for (let n = 0; ; n++) { + try { await play.call('PATCH', url, x.body); return; } catch (e) { + const le = parseLimitError((e as Error).message); + const cur = le ? x.all.find(r => r.regionCode === le.region) : undefined; + if (!le || !cur || n >= 40 || !learnLimit(limits, le, cur.price.currencyCode)) throw e; + saveLimits(root, limits); + const lim = applyLimits(x.all, limits, skipOut); + x.regions = lim.regions.length; x.clamps = lim.clamps; x.body = x.mkBody(lim.regions); + warn('play', `${x.tag}: Play limit for ${le.region} is ${le.min}-${le.max} (saved in .storebound/play-price-limits.json); ${skipOut ? 'region left out' : 'clamped'}, sending again`); } - } else if (!x.cur || x.changes.some(c => c.owner || c.text.startsWith('listings '))) { - const mask = x.cur && !ownerChange ? 'listings' : 'listings,purchaseOptions'; - await play.call('PATCH', `${play.API}/onetimeproducts/${id}?allowMissing=true®ionsVersion.version=${v}&updateMask=${mask}`, x.body); - log('play', `${x.tag}: ${x.cur ? 'updated' : 'created'} · ${x.regions} regions · ${x.listings} listings`); } - // a new product's option starts as DRAFT; an existing one is activated after the confirmation - if (optionOf(x)?.state === 'ACTIVE' || (x.cur && (!activateOk || !activate.includes(x)))) continue; - try { - await play.call('POST', `${play.API}/oneTimeProducts/${id}/purchaseOptions:batchUpdateStates`, { - requests: [{ activatePurchaseOptionRequest: { packageName: pkg, productId: x.p.id, purchaseOptionId: OPTION } }], - }); - log('play', `${x.tag}: purchase option activated`); - } catch (e) { warn('play', `${x.tag}: activate failed: ${(e as Error).message}`); } } - const created = plan.filter(x => !x.cur && x.changes.length).map(x => x.p.id); + + for (const x of plan) { + if (!x.changes.length) { results.push({ id: x.p.id, ok: true, text: 'unchanged' }); continue; } + try { + const id = encodeURIComponent(x.p.id); + const v = encodeURIComponent(x.version); + const ownerChange = !!x.cur && x.changes.some(c => c.owner); + let text = x.cur ? 'updated' : 'created'; + if (x.cur && ownerChange && !priceOk) { + held.push({ id: x.p.id, changes: x.changes.flatMap(c => c.prices ?? []) }); + text = 'prices held for the owner'; + if (x.changes.some(c => c.text.startsWith('listings '))) { + await patch(x, `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`); + log('play', `${x.tag}: listings updated (prices unchanged)`); + } + } else if (!x.cur || x.changes.some(c => c.owner || c.text.startsWith('listings '))) { + const mask = x.cur && !ownerChange ? 'listings' : 'listings,purchaseOptions'; + await patch(x, `${play.API}/onetimeproducts/${id}?allowMissing=true®ionsVersion.version=${v}&updateMask=${mask}`); + log('play', `${x.tag}: ${x.cur ? 'updated' : 'created'} · ${x.regions} regions · ${x.listings} listings`); + } + if (x.clamps.length) text += ` (${x.clamps.length} region(s) ${skipOut ? 'left out' : 'clamped'}: ${x.clamps.map(c => c.region).join(', ')})`; + // a new product's option starts as DRAFT; an existing one is activated after the confirmation + if (!(optionOf(x)?.state === 'ACTIVE' || (x.cur && (!activateOk || !activate.includes(x))))) { + try { + await play.call('POST', `${play.API}/oneTimeProducts/${id}/purchaseOptions:batchUpdateStates`, { + requests: [{ activatePurchaseOptionRequest: { packageName: pkg, productId: x.p.id, purchaseOptionId: OPTION } }], + }); + log('play', `${x.tag}: purchase option activated`); + } catch (e) { warn('play', `${x.tag}: activate failed: ${(e as Error).message}`); text += ' (activation failed)'; } + } + results.push({ id: x.p.id, ok: true, text }); + } catch (e) { + const msg = (e as Error).message.replace(/\s+/g, ' ').slice(0, 300); + warn('play', `${x.tag}: FAILED: ${msg}; going on with the next product`); + results.push({ id: x.p.id, ok: false, text: msg }); + } + } + const failedIds = new Set(results.filter(r => !r.ok).map(r => r.id)); + const created = plan.filter(x => !x.cur && x.changes.length && !failedIds.has(x.p.id)).map(x => x.p.id); if (created.length) reportMissing('play', 'new products', created, (await listOneTimeProducts(play)).map(p => p.productId), { fail: false }); holdPriceChanges(held); + const failed = results.filter(r => !r.ok); + if (failed.length || plan.some(x => x.clamps.length)) for (const r of results) log('play', `result ${r.id}: ${r.ok ? 'ok' : 'FAILED'} · ${r.text}`); + if (failed.length) { + process.exitCode = 1; + warn('play', `${failed.length} of ${results.length} product(s) failed (${failed.map(f => f.id).join(', ')}); the others are written. Fix the cause and run \`storebound play products --dry\`, then again with --yes: finished products are unchanged.`); + return; + } done('play', held.length ? 'products done, price changes left for the owner' : 'products done'); } diff --git a/cli/play/subscriptions.ts b/cli/play/subscriptions.ts index ab037b0..5fcfdcf 100644 --- a/cli/play/subscriptions.ts +++ b/cli/play/subscriptions.ts @@ -19,6 +19,7 @@ import { confirm, need } from '../lib/ctx'; import { done, log, step, warn } from '../lib/log'; import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges } from '../lib/price-gate'; import { type Money, toMoney } from '../lib/money'; +import { applyLimits, clampText, learnLimit, loadLimits, parseLimitError, saveLimits } from '../lib/play-limits'; import { allSubscriptions, basePlanId, offerId, type PlayOfferLike, playOfferBody, playOfferDiff, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, playSubscriptionBody, playSubTextChanges, @@ -58,13 +59,15 @@ export async function run(ctx: Ctx): Promise { return { convertedRegionPrices: conv.convertedRegionPrices, convertedOtherRegionsPrice: conv.convertedOtherRegionsPrice, regionVersion: conv.regionVersion ?? { version: '2022/02' } }; }; + const limits = loadLimits(root); + const skipOut = ctx.args.has('skip-out-of-range-regions'); const prune = ctx.args.has('prune-languages'); // the offer the config would create (pay-up-front prices come from convertRegionPrices of the offer price) const buildOffer = async (s: typeof subs[number], regions: { regionCode: string; price: Money }[], o: { type: string; usd?: number }, extra?: NonNullable[number]) => { let offerRegions; if (o.type === 'pay-up-front') { const oc = await convert(o.usd!); - offerRegions = { regions: Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), other: oc.convertedOtherRegionsPrice }; + offerRegions = { regions: applyLimits(Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), limits, skipOut).regions, other: oc.convertedOtherRegionsPrice }; } return playOfferBody(pkg, s, regions, offerRegions, extra); }; @@ -73,7 +76,9 @@ export async function run(ctx: Ctx): Promise { const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType; version: string; offerMissing: boolean; extraMissing: string[]; offerDrift: OfferDrift[] }[] = []; for (const [i, s] of subs.entries()) { const conv = await convert(s.usd); - const regions = playSubRegions(s, conv.convertedRegionPrices, csv); + const lim = applyLimits(playSubRegions(s, conv.convertedRegionPrices, csv), limits, skipOut); + const regions = lim.regions; + for (const c of lim.clamps) warn(TAG, `${s.id}: price limit ${clampText(c)}${skipOut ? '' : ' (clamped; --skip-out-of-range-regions leaves such regions out)'}`); const body = playSubscriptionBody(pkg, s, config.languages, regions, conv.convertedOtherRegionsPrice); const cur = existing.get(s.id) ?? null; const diff = playSubDiff(cur, body); @@ -135,7 +140,9 @@ export async function run(ctx: Ctx): Promise { if (!liveOk && priced.length) log(TAG, 'writing what is new; price changes of existing base plans and offers wait for the owner (--allow-price-changes)'); const held: HeldPrice[] = []; + const failedSubs: string[] = []; for (const { s, body, diff, version, offerMissing, extraMissing, offerDrift } of todo) { + try { const v = encodeURIComponent(version); const id = encodeURIComponent(s.id); const cur = existing.get(s.id); @@ -186,7 +193,23 @@ export async function run(ctx: Ctx): Promise { await play.call('PATCH', `${offerUrl}/${encodeURIComponent(d.offerId)}?updateMask=phases®ionsVersion.version=${v}`, d.body); log(TAG, `${s.id}: offer ${d.offerId} updated (${[...d.phase, ...(d.prices.length ? [`${d.prices.length} price(s)`] : [])].join('; ')})`); } + } catch (e) { + const msg = (e as Error).message.replace(/\s+/g, ' ').slice(0, 300); + const le = parseLimitError(msg); + const cur = le ? body.basePlans?.[0]?.regionalConfigs?.find(r => r.regionCode === le.region)?.price?.currencyCode : undefined; + if (le && cur && learnLimit(limits, le, cur)) { + saveLimits(root, limits); + warn(TAG, `${s.id}: Play limit for ${le.region} is ${le.min}-${le.max}, saved in .storebound/play-price-limits.json: run again and that region is ${skipOut ? 'left out' : 'clamped'}`); + } + warn(TAG, `${s.id}: FAILED: ${msg}; going on with the next subscription`); + failedSubs.push(s.id); + } } holdPriceChanges(held); + if (failedSubs.length) { + process.exitCode = 1; + warn(TAG, `${failedSubs.length} of ${todo.length} subscription(s) failed (${failedSubs.join(', ')}); the others are written. Fix the cause, run --dry, then again with --yes.`); + return; + } done(TAG, held.length ? 'subscriptions done, price changes left for the owner' : 'subscriptions done'); } diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 54f25ad..bef6ea6 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1323,3 +1323,30 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `cli/lib/ship-step.ts` holds the one exit-code mapping; `play upload` and `apple upload` check the store before sending and stop with exit 7 (`tests/ship-step.test.ts`, fake stores); `--env-file` values are scrubbed from every output. + +## 111. `play products` died on a high price: "Price for KR must be between ₩70 and ₩600,000" + +- **What happens:** the regional prices come from `convertRegionPrices` (a USD base converted per region). Play does + not check its own min / max there, so a 649.99 USD product converts to ₩950,000 in KR and the PATCH is refused 400. + The run used to stop at that product, after the earlier ones were already created and activated. +- **Do:** run `storebound play products --dry`: it names every region outside its min / max (`price limits on : + KR KRW 942486 -> 600000`). The write clamps those regions to the bound with a warning; `--skip-out-of-range-regions` + leaves them out instead. A limit Play reports that storebound did not know is learned from the 400, saved in + `.storebound/play-price-limits.json` and the PATCH is sent again. Set an explicit `overrides` price if you want a + different price in that region. +- **Guard:** `cli/lib/play-limits.ts` (seed table, 400 parser, clamp); one product failing no longer stops the others: + every product gets a `result` line and the run exits 1 at the end. Play subscriptions (base plan and pay-up-front + offer regions) use the same clamp and keep going after a failed subscription. `tests/price-limits-locked-loc.test.ts`. + +## 112. `apple iap` died on an approved product: "Cannot edit InAppPurchaseLocalization when it is in ACTIVE state" + +- **What happens:** once an in-app purchase (or subscription, subscription group) is approved, its localizations are + live. PATCH answers 409 `ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE`, and the run stopped there, before the price + schedule of that and the later products. +- **Do:** run `storebound apple iap --dry`: a changed name / description on an APPROVED / ACTIVE localization is + marked "not editable now (changes ship with the next review of the product / needs a new localization)". The write + skips those edits with a warning and still applies prices, attributes, availability and the other products. To change + the live text, edit it in the product's next review round (App Store Connect) or add a new localization. +- **Guard:** `cli/lib/loc-lock.ts` reads the localization `state` (IAP, subscription and group localizations) and treats + a 409 UNMODIFIABLE the same way; one IAP failing no longer stops the others (results at the end, exit 1). + `tests/price-limits-locked-loc.test.ts` (fake ASC answers the 409 for APPROVED / ACTIVE localizations). diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index 756ebfc..bf5c880 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -143,6 +143,10 @@ export class FakeAsc { /** The attached builds carry com.apple.developer.game-center: submitting answers Apple's 409 (seen live) until the * app has a gameCenterDetail and the version a gameCenterAppVersion. */ gameCenterBuild = false; + /** localization ids whose PATCH answers 409 UNMODIFIABLE besides those in state APPROVED / ACTIVE */ + unmodifiable = new Set(); + /** ids whose PATCH was refused with that 409 */ + patchRefused: string[] = []; constructor(readonly appId = '6400000101') { this.put({ type: 'apps', id: appId, attributes: { bundleId: 'com.example.notes', name: 'Example Notes' }, rels: {} }); @@ -327,6 +331,12 @@ export class FakeAsc { // the age rating declaration is addressed by its app info's id if (self && type && self.type !== type) self = this.children(self.id, type)[0]; if (!self) return json({ errors: [{ status: '404' }] }, 404); + // seen live (2026-10-23): a live (approved) localization cannot be edited + if (/^(inAppPurchase|subscription|subscriptionGroup)Localizations$/.test(self.type) && (['APPROVED', 'ACTIVE'].includes(String(self.attributes.state)) || this.unmodifiable.has(self.id))) { + this.patchRefused.push(self.id); + return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE', title: 'The provided entity includes an attribute with an invalid value', + detail: `Cannot edit ${self.type.replace(/s$/, '').replace(/^./, c => c.toUpperCase())} when it is in ACTIVE state` }] }, 409); + } const attrs = (body as { data: { attributes?: Record } }).data.attributes ?? {}; for (const [k, v] of Object.entries((body as { data: { relationships?: Record } }).data.relationships ?? {})) { if (v.data) self.rels[k] = v.data.id; else delete self.rels[k]; @@ -405,6 +415,12 @@ export class FakePlay { /** How many validate / commit calls without changesNotSentForReview=true Play refuses with its 400 "Changes cannot * be sent for review automatically" (after a rejection, pending changes, managed publishing: pitfall 72). */ refuseAutoReview = 0; + /** convertRegionPrices also answers KR (KRW, 1450 per USD) */ + convertKr = false; + /** per region the min / max Play accepts in a product's regional price (live: KR 70-600000 KRW); a PATCH outside answers 400 */ + priceLimits: Record = {}; + /** product id -> 400 message for its PATCH */ + rejectProducts: Record = {}; /** validate calls that carried changesNotSentForReview (live Play answers them 400 "Unknown name") */ validateWithNotSent = 0; @@ -454,7 +470,8 @@ export class FakePlay { if (method === 'POST' && p === '/pricing:convertRegionPrices') { const usd = body as { price: { units?: string; nanos?: number } }; const price = { currencyCode: 'USD', units: usd.price.units ?? '0', nanos: usd.price.nanos ?? 0 }; - return json({ convertedRegionPrices: { US: { regionCode: 'US', price }, DE: { regionCode: 'DE', price: { ...price, currencyCode: 'EUR' } } }, + const kr = this.convertKr ? { KR: { regionCode: 'KR', price: { currencyCode: 'KRW', units: String(Math.round((Number(price.units) + price.nanos / 1e9) * 1450)), nanos: 0 } } } : {}; + return json({ convertedRegionPrices: { US: { regionCode: 'US', price }, DE: { regionCode: 'DE', price: { ...price, currencyCode: 'EUR' } }, ...kr }, convertedOtherRegionsPrice: { usdPrice: price, eurPrice: { ...price, currencyCode: 'EUR' } }, regionVersion: { version: '2022/02' } }); } if (method === 'GET' && p === '/oneTimeProducts') return json({ oneTimeProducts: [...this.products.values()] }); @@ -463,7 +480,14 @@ export class FakePlay { if (method === 'PATCH') { const cur = this.products.get(id); if (!cur && u.searchParams.get('allowMissing') !== 'true') return err(404, 'NOT_FOUND', 'no product'); + if (this.rejectProducts[id]) return err(400, 'INVALID_ARGUMENT', this.rejectProducts[id]!); const b = clone(body as Record); + for (const o of (b.purchaseOptions as { regionalPricingAndAvailabilityConfigs?: { regionCode: string; price: { units?: string; nanos?: number } }[] }[] | undefined) ?? []) + for (const r of o.regionalPricingAndAvailabilityConfigs ?? []) { + const l = this.priceLimits[r.regionCode]; + const v = Number(r.price.units ?? 0) + (r.price.nanos ?? 0) / 1e9; + if (l && (v < l.min || v > l.max)) return err(400, 'INVALID_ARGUMENT', `Price for ${r.regionCode} must be between ${l.symbol}${l.min.toLocaleString('en-US')} and ${l.symbol}${l.max.toLocaleString('en-US')}, found ${l.symbol}${v.toLocaleString('en-US')}`); + } const mask = (u.searchParams.get('updateMask') ?? '').split(','); const next = cur ? { ...cur, ...(mask.includes('listings') ? { listings: b.listings } : {}), ...(mask.includes('purchaseOptions') ? { purchaseOptions: b.purchaseOptions } : {}) } : b; for (const o of (next.purchaseOptions as { state?: string; purchaseOptionId: string }[] | undefined) ?? []) { diff --git a/tests/price-limits-locked-loc.test.ts b/tests/price-limits-locked-loc.test.ts new file mode 100644 index 0000000..431c66a --- /dev/null +++ b/tests/price-limits-locked-loc.test.ts @@ -0,0 +1,137 @@ +// Real use 2026-10-23: (1) `play products --yes` died at a 649.99 USD product: Play refused the converted KR price +// ("Price for KR must be between ₩70 and ₩600,000, found ₩950,000") after earlier products were written; (2) `apple iap +// --allow-price-changes --yes` died at an approved IAP: 409 ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE "Cannot edit +// InAppPurchaseLocalization when it is in ACTIVE state". Both against the stateful fakes with those answers. +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { run as appleIap } from '../cli/apple/iap'; +import type { ShipConfig } from '../cli/lib/config'; +import { applyLimits, parseLimitError, parseLocalAmount } from '../cli/lib/play-limits'; +import { isLockedLocState, isUnmodifiableError } from '../cli/lib/loc-lock'; +import { run as playProducts } from '../cli/play/products'; +import { upsertTiming } from '../cli/lib/upsert'; +import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; + +let s: Stores; +beforeAll(() => { ascEnv(); playEnv(); }); +afterAll(restore); +beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); }); +afterEach(uninstallStores); + +const L = { en: { title: 'Pack', description: 'A pack.' } }; +const BIG = 'com.example.notes.credits_big'; +const SMALL = 'com.example.notes.credits_small'; +const cfg = (...ids: [string, number][]): ShipConfig => ({ ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], + products: ids.map(([id, usd]) => ({ id, type: 'consumable' as const, usd, listings: L })) }); +const region = (id: string, code: string) => ((s.play.products.get(id)?.purchaseOptions as { regionalPricingAndAvailabilityConfigs: { regionCode: string; price: { units: string } }[] }[] | undefined)?.[0]?.regionalPricingAndAvailabilityConfigs ?? []).find(r => r.regionCode === code); + +describe('play price limits (pure)', () => { + test('Play\'s 400 text is parsed, amounts with symbols and thousands separators too', () => { + expect(parseLimitError('PATCH /x -> 400 Price for KR must be between ₩70 and ₩600,000, found ₩950,000')).toEqual({ region: 'KR', min: 70, max: 600000, found: 950000 }); + expect(parseLimitError('Price for DE must be between €0.5 and €500.00, found €649.99.')).toEqual({ region: 'DE', min: 0.5, max: 500, found: 649.99 }); + expect(parseLimitError('some other 400')).toBeNull(); + expect(parseLocalAmount('₩1,234,567')).toBe(1234567); + }); + test('applyLimits clamps to max / min, or leaves the region out', () => { + const m = (currencyCode: string, units: string) => ({ currencyCode, units }); + const rows = [{ regionCode: 'KR', price: m('KRW', '950000') }, { regionCode: 'US', price: m('USD', '5') }]; + const lim = { KR: { currency: 'KRW', min: 70, max: 600000 } }; + const c = applyLimits(rows, lim); + expect(c.regions[0]!.price.units).toBe('600000'); + expect(c.clamps).toEqual([{ region: 'KR', currency: 'KRW', from: 950000, to: 600000, bound: 'max' }]); + expect(applyLimits(rows, lim, true).regions.map(r => r.regionCode)).toEqual(['US']); + expect(applyLimits([{ regionCode: 'KR', price: m('KRW', '10') }], lim).clamps[0]!.bound).toBe('min'); + }); + test('Apple\'s 409 and the locked states', () => { + expect(isUnmodifiableError(new Error('PATCH /v1/x -> 409 ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE Cannot edit InAppPurchaseLocalization when it is in ACTIVE state'))).toBe(true); + expect(isLockedLocState('APPROVED') && isLockedLocState('ACTIVE') && !isLockedLocState('PREPARE_FOR_SUBMISSION')).toBe(true); + }); +}); + +describe('play products: regions outside Play\'s min / max (KR 649.99 USD)', () => { + beforeEach(() => { s.play.convertKr = true; s.play.priceLimits.KR = { min: 70, max: 600000, symbol: '₩' }; }); + + test('dry run names the region, old -> new; the write clamps it (one PATCH, no 400) and the next run is unchanged', async () => { + const p = project(cfg([SMALL, 0.99], [BIG, 649.99])); + const d = await capture(() => playProducts(p.ctx(['--dry']))); + expect(d.out).toContain(`price limits on ${BIG}`); + expect(d.out).toMatch(/KR KRW 942486 -> 600000 \(above Play's max/); + expect(d.out).not.toContain(`price limits on ${SMALL}`); + const r = await capture(() => playProducts(p.ctx(['--yes']))); + expect(r.exit).toBeNull(); + expect(region(BIG, 'KR')?.price.units).toBe('600000'); + expect(region(SMALL, 'KR')?.price.units).toBe('1436'); + const again = await capture(() => playProducts(p.ctx(['--dry']))); + expect(again.out).toContain('nothing to change, 2 unchanged'); + }); + + test('--skip-out-of-range-regions leaves the region out', async () => { + const p = project(cfg([BIG, 649.99])); + const r = await capture(() => playProducts(p.ctx(['--yes', '--skip-out-of-range-regions']))); + expect(r.exit).toBeNull(); + expect(region(BIG, 'KR')).toBeUndefined(); + expect(region(BIG, 'US')?.price.units).toBe('649'); + }); + + test('a limit not in the table is learned from Play\'s 400, saved, and the PATCH sent again clamped', async () => { + s.play.priceLimits.DE = { min: 0.5, max: 500, symbol: '€' }; + const p = project(cfg([BIG, 649.99])); + const r = await capture(() => playProducts(p.ctx(['--yes']))); + expect(r.exit).toBeNull(); + expect(r.err).toContain('Play limit for DE is 0.5-500'); + expect(region(BIG, 'DE')?.price.units).toBe('500'); + const file = join(p.root, '.storebound', 'play-price-limits.json'); + expect(existsSync(file)).toBe(true); + expect(JSON.parse(readFileSync(file, 'utf8')).DE.max).toBe(500); + // the next dry run knows it without a 400 + const d = await capture(() => playProducts(p.ctx(['--dry']))); + expect(d.out).not.toContain('price limits on'); + }); + + test('one product failing does not stop the others: results per product, exit 1 at the end', async () => { + s.play.rejectProducts[BIG] = 'Some other refusal'; + const p = project(cfg([BIG, 5], [SMALL, 0.99])); + const r = await capture(() => playProducts(p.ctx(['--yes']))); + expect(s.play.products.has(SMALL)).toBe(true); + expect(s.play.products.has(BIG)).toBe(false); + expect(r.exit).toBe(1); + expect(r.err).toContain('1 of 2 product(s) failed'); + expect(r.out + r.err).toContain(`result ${SMALL}: ok`); + expect(r.out + r.err).toContain(`result ${BIG}: FAILED`); + }); +}); + +describe('apple iap: live (approved) localizations cannot be edited', () => { + const two = (d1: string, d2: string): ShipConfig => ({ ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], + products: [{ id: BIG, type: 'consumable', usd: 1.99, listings: { en: { title: 'Big', description: d1 } } }, { id: SMALL, type: 'consumable', usd: 0.99, listings: { en: { title: 'Small', description: d2 } } }] }); + + test('dry run marks it, the write skips it (no PATCH sent) and goes on; a failing IAP does not abort the rest', async () => { + await capture(() => appleIap(project(two('one', 'one')).ctx([]))); + const locs = s.asc.all('inAppPurchaseLocalizations'); + expect(locs).toHaveLength(2); + for (const l of locs) l.attributes.state = 'APPROVED'; + const p = project(two('two', 'two')); + const d = await capture(() => appleIap(p.ctx(['--dry']))); + expect(d.out).toContain('not editable now'); + expect(d.out).toContain('next review of the product'); + const r = await capture(() => appleIap(p.ctx(['--allow-price-changes', '--yes']))); + expect(r.exit).toBeNull(); + expect(s.asc.patchRefused).toEqual([]); + expect(r.err).toContain('2 localization edit(s) skipped'); + // an unknown locked state: the 409 itself is skipped with a warning + for (const l of locs) l.attributes.state = 'SOMETHING_NEW'; + s.asc.unmodifiable.add(locs[0]!.id); + const r2 = await capture(() => appleIap(project(two('three', 'three')).ctx(['--yes']))); + expect(r2.exit).toBeNull(); + expect(s.asc.patchRefused).toEqual([locs[0]!.id]); + expect(r2.err).toContain('skipped'); + expect(locs[1]!.attributes.description).toBe('three'); + // a product that throws (description over Apple's limit) is reported at the end, the next one still runs + const r3 = await capture(() => appleIap(project(two('x'.repeat(5000), 'four')).ctx(['--yes']))); + expect(r3.exit).toBe(1); + expect(r3.err).toContain('1 of 2 product(s) failed'); + expect(locs[1]!.attributes.description).toBe('four'); + }); +});