// 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; }