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:
parent
882255a32f
commit
b0641c1fc8
12 changed files with 456 additions and 69 deletions
|
|
@ -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<void> {
|
|||
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<string, unknown>; version: string; cur?: Existing; changes: ProductChange[]; regions: number; listings: number }[] = [];
|
||||
const plan: { p: (typeof products)[number]; tag: string; body: Record<string, unknown>; version: string; cur?: Existing; changes: ProductChange[]; regions: number; listings: number;
|
||||
all: RegionConfig[]; clamps: Clamp[]; mkBody: (regions: RegionConfig[]) => Record<string, unknown> }[] = [];
|
||||
for (const [i, p] of products.entries()) {
|
||||
const tag = step(i + 1, products.length, p.id);
|
||||
const conv = await play.call<Converted>('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<string, unknown> => ({
|
||||
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<void> {
|
|||
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<void> {
|
||||
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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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®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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue