78 lines
4.4 KiB
TypeScript
78 lines
4.4 KiB
TypeScript
// 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;
|
|
}
|