play products: clamp regional prices to Play's min/max, per-product results; apple iap: skip live localizations, per-product results

This commit is contained in:
CRTkafa 2026-10-23 16:52:28 +08:00
parent 882255a32f
commit b0641c1fc8
12 changed files with 456 additions and 69 deletions

View file

@ -20,7 +20,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args {
const repeated = new Map<string, string[]>();
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; }

16
cli/lib/loc-lock.ts Normal file
View file

@ -0,0 +1,16 @@
// App Store Connect refuses to edit a localization that is already live: PATCH /v1/inAppPurchaseLocalizations/<id> (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));

78
cli/lib/play-limits.ts Normal file
View file

@ -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 <project>/.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<string, RegionLimit>;
/** 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<T extends { regionCode: string; price: Money }>(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;
}