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

@ -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<void> {
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<typeof s.offers>[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<void> {
const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType<typeof playSubDiff>; 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<void> {
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<void> {
await play.call('PATCH', `${offerUrl}/${encodeURIComponent(d.offerId)}?updateMask=phases&regionsVersion.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');
}