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
|
|
@ -13,7 +13,8 @@ import { APPLE_IAP_LIMITS } from '../lib/copy';
|
|||
import type { Ctx } from '../lib/ctx';
|
||||
import { need } from '../lib/ctx';
|
||||
import { dryPlan, type PlanRow } from '../lib/dry';
|
||||
import { done, log, step } from '../lib/log';
|
||||
import { isLockedLocState, isUnmodifiableError, lockedLocNote } from '../lib/loc-lock';
|
||||
import { done, log, step, warn } from '../lib/log';
|
||||
import { LAG_HINT, reportMissing } from '../lib/store-lag';
|
||||
import { appleTerritory, nearestPrices, samePrice } from '../lib/money';
|
||||
import { createOnce, findOrCreate, getById } from '../lib/upsert';
|
||||
|
|
@ -27,8 +28,9 @@ export const APPLE_IAP_TYPE = { consumable: 'CONSUMABLE', non_consumable: 'NON_C
|
|||
// one plan line per change: store texts carry line breaks, which broke the dry-run table
|
||||
const clip = (s: string, n = 40): string => { const a = [...s.replace(/\s+/g, ' ').trim()]; return a.length > n ? `${a.slice(0, n - 1).join('')}…` : a.join(''); };
|
||||
|
||||
export type IapLocalization = { locale: string; id?: string; name: string; description: string };
|
||||
export type IapLocalizationChange = { locale: string; id: string; attrs: { name?: string; description?: string }; note: string };
|
||||
export type IapLocalization = { locale: string; id?: string; name: string; description: string; state?: string };
|
||||
/** `locked`: the localization is live (APPROVED / ACTIVE) and Apple refuses the edit; the note says so. */
|
||||
export type IapLocalizationChange = { locale: string; id: string; attrs: { name?: string; description?: string }; note: string; locked?: string };
|
||||
/** Existing locales whose name/description differ from the config: the attributes to PATCH and a short plan note. */
|
||||
export function iapLocalizationChanges(store: IapLocalization[], wanted: IapLocalization[]): IapLocalizationChange[] {
|
||||
const out: IapLocalizationChange[] = [];
|
||||
|
|
@ -39,7 +41,9 @@ export function iapLocalizationChanges(store: IapLocalization[], wanted: IapLoca
|
|||
const parts: string[] = [];
|
||||
if ((s.name ?? '') !== w.name) { attrs.name = w.name; parts.push(`name "${clip(s.name ?? '')}" -> "${clip(w.name)}"`); }
|
||||
if ((s.description ?? '') !== w.description) { attrs.description = w.description; parts.push(`description "${clip(s.description ?? '')}" -> "${clip(w.description)}"`); }
|
||||
if (parts.length) out.push({ locale: w.locale, id: s.id, attrs, note: `localization ${w.locale}: ${parts.join(', ')}` });
|
||||
if (!parts.length) continue;
|
||||
const locked = isLockedLocState(s.state) ? s.state : undefined;
|
||||
out.push({ locale: w.locale, id: s.id, attrs, note: `localization ${w.locale}: ${locked ? `${lockedLocNote(locked)}: ` : ''}${parts.join(', ')}`, ...(locked ? { locked } : {}) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -94,7 +98,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const live = (await appleCurrent(c, id, p.id, false)).rows.filter(x => !x.startDate || x.startDate <= new Date().toISOString().slice(0, 10));
|
||||
return wanted(p).flatMap(w => { const cur = live.find(x => x.territory === w.territory); return cur && !samePrice(cur.price, w.price) ? [`${w.territory} ${cur.price} -> ${w.price}`] : []; });
|
||||
}
|
||||
const locOf = (x: J) => ({ locale: x.attributes.locale as string, name: (x.attributes.name ?? '') as string, description: (x.attributes.description ?? '') as string });
|
||||
const locOf = (x: J) => ({ locale: x.attributes.locale as string, name: (x.attributes.name ?? '') as string, description: (x.attributes.description ?? '') as string, state: x.attributes.state as string | undefined });
|
||||
const wantedLocs = (p: (typeof products)[number]) => Object.entries(p.listings).flatMap(([k, l]) => { const locale = langs.get(k)?.apple; return locale ? [{ locale, name: l.title, description: l.description }] : []; });
|
||||
const wantedAttrs = (p: (typeof products)[number]): IapAttributes => ({ name: p.referenceName ?? Object.values(p.listings)[0]!.title, reviewNote: p.reviewNote, familySharable: p.familySharable });
|
||||
/** The store's availability as territory ids + the new-territories flag (null when there is none). */
|
||||
|
|
@ -117,11 +121,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const allowPrices = ctx.args.has('allow-price-changes');
|
||||
const rows: PlanRow[] = [];
|
||||
const created: string[] = [];
|
||||
const results: { id: string; ok: boolean; text: string }[] = [];
|
||||
let skippedLocs = 0;
|
||||
for (const [i, p] of products.entries()) {
|
||||
const tag = step(i + 1, products.length, p.id);
|
||||
const lookup = async (): Promise<J | null> => (await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`)).data
|
||||
?.find((x: J) => x.attributes?.productId === p.id) ?? null;
|
||||
if (dry) { const f = await lookup(); rows.push(await planProduct(p, f?.id, f?.attributes?.state)); continue; }
|
||||
try {
|
||||
// product ids are unique per app: a 409 finds the existing one; the journal covers the list lag after a create
|
||||
const first = Object.values(p.listings)[0]!;
|
||||
const made = await findOrCreate<J>({ what: `in-app purchase ${p.id}`, tag: 'apple', unique: true, find: lookup,
|
||||
|
|
@ -148,10 +155,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
log('apple', ` localization ${locale}`);
|
||||
}
|
||||
for (const ch of iapLocalizationChanges(storeLocs.map((x: J) => ({ id: x.id, ...locOf(x) })), wantedLocs(p))) {
|
||||
if (ch.locked) { warn('apple', ` ${ch.note}; skipped`); skippedLocs++; continue; }
|
||||
const l = Object.entries(p.listings).find(([k]) => langs.get(k)?.apple === ch.locale)![1];
|
||||
if ([...l.description].length > APPLE_IAP_LIMITS.description) throw new Error(`${p.id} ${ch.locale}: description over ${APPLE_IAP_LIMITS.description} characters (Apple limit)`);
|
||||
if ([...l.title].length > APPLE_IAP_LIMITS.name) throw new Error(`${p.id} ${ch.locale}: name over ${APPLE_IAP_LIMITS.name} characters (Apple limit)`);
|
||||
await c.asc('PATCH', `/v1/inAppPurchaseLocalizations/${ch.id}`, { data: { type: 'inAppPurchaseLocalizations', id: ch.id, attributes: ch.attrs } });
|
||||
try { await c.asc('PATCH', `/v1/inAppPurchaseLocalizations/${ch.id}`, { data: { type: 'inAppPurchaseLocalizations', id: ch.id, attributes: ch.attrs } }); } catch (e) {
|
||||
if (!isUnmodifiableError(e)) throw e;
|
||||
warn('apple', ` localization ${ch.locale}: ${lockedLocNote('ACTIVE')}; skipped`); skippedLocs++; continue;
|
||||
}
|
||||
log('apple', ` ${ch.note}`);
|
||||
}
|
||||
if (!made.created) {
|
||||
|
|
@ -183,6 +194,12 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
availableTerritories: { data: territories.map(t => ({ type: 'territories', id: t })) } } } });
|
||||
log('apple', ` ${avail?.data ? availDiff.note : `available in ${territories.length} territories + new ones`}`);
|
||||
}
|
||||
results.push({ id: p.id, ok: true, text: 'ok' });
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message.replace(/\s+/g, ' ').slice(0, 300);
|
||||
warn('apple', `${tag}: FAILED: ${msg}; going on with the next product`);
|
||||
results.push({ id: p.id, ok: false, text: msg });
|
||||
}
|
||||
}
|
||||
if (dry) {
|
||||
console.log(dryPlan('apple iap', rows));
|
||||
|
|
@ -196,6 +213,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const listed = (await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)).map((x: J) => x.attributes.productId as string);
|
||||
if (reportMissing('apple', 'new products', created, listed, { fail: false }).length) log('apple', 'wait a minute before apple iap-review, or it cannot see them');
|
||||
}
|
||||
const failed = results.filter(r => !r.ok);
|
||||
if (skippedLocs) warn('apple', `${skippedLocs} localization edit(s) skipped: the localization is live (APPROVED / ACTIVE). The text changes with the next review of the product, or add a new localization.`);
|
||||
if (failed.length) {
|
||||
process.exitCode = 1;
|
||||
warn('apple', `${failed.length} of ${results.length} product(s) failed (${failed.map(f => f.id).join(', ')}): ${failed.map(f => `${f.id}: ${f.text}`).join(' | ')}. The others are written. Fix the cause, run \`storebound apple iap --dry\`, then again with --yes.`);
|
||||
return;
|
||||
}
|
||||
done('apple', 'iap done (next: apple iap-review for the review screenshots)');
|
||||
|
||||
/** What a real run would do to one product, from read-only calls. */
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { type OfferCodeDef, sellsOn, type Subscription, type SubscriptionGroup } from '../lib/config';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { isLockedLocState, isUnmodifiableError, lockedLocNote } from '../lib/loc-lock';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { done, log, step, warn } from '../lib/log';
|
||||
import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges, withoutYes } from '../lib/price-gate';
|
||||
|
|
@ -73,7 +74,7 @@ const heldOf = (id: string, changes: { territory: string; from: number | null; t
|
|||
/** Everything an existing subscription has that differs from the config (pure data; the run prints and applies it). */
|
||||
export interface SubDrift {
|
||||
attrs: AppleAttrDiff;
|
||||
locs: { id: string; loc: string; patch: Record<string, string> }[];
|
||||
locs: { id: string; loc: string; patch: Record<string, string>; locked?: string }[];
|
||||
availability: { missing: string[]; newTerritories: boolean } | null;
|
||||
intro: string[];
|
||||
}
|
||||
|
|
@ -88,7 +89,7 @@ export async function readSubDrift(c: Asc, s: Subscription, id: string, locale:
|
|||
const loc = locale.get(lang);
|
||||
const h = loc ? haveLocs.find((x: J) => x.attributes.locale === loc) : undefined;
|
||||
const patch = h ? appleLocDiff(l, { name: h.attributes.name, description: h.attributes.description }) : null;
|
||||
if (h && patch && loc) locs.push({ id: h.id, loc, patch });
|
||||
if (h && patch && loc) locs.push({ id: h.id, loc, patch, ...(isLockedLocState(h.attributes.state) ? { locked: h.attributes.state as string } : {}) });
|
||||
}
|
||||
let availability: SubDrift['availability'] = null;
|
||||
const av = (await c.maybe(`/v1/subscriptions/${id}/subscriptionAvailability`))?.data;
|
||||
|
|
@ -103,14 +104,14 @@ export async function readSubDrift(c: Asc, s: Subscription, id: string, locale:
|
|||
}
|
||||
|
||||
/** Group localizations whose name / customAppName differ from the config. Read-only. */
|
||||
export async function readGroupLocDrift(c: Asc, g: SubscriptionGroup, groupId: string, locale: Map<string, string | undefined>): Promise<{ id: string; loc: string; patch: Record<string, string> }[]> {
|
||||
export async function readGroupLocDrift(c: Asc, g: SubscriptionGroup, groupId: string, locale: Map<string, string | undefined>): Promise<{ id: string; loc: string; patch: Record<string, string>; locked?: string }[]> {
|
||||
const have = await c.all(`/v1/subscriptionGroups/${groupId}/subscriptionGroupLocalizations?limit=200`);
|
||||
const out: { id: string; loc: string; patch: Record<string, string> }[] = [];
|
||||
const out: { id: string; loc: string; patch: Record<string, string>; locked?: string }[] = [];
|
||||
for (const [lang, l] of Object.entries(g.listings)) {
|
||||
const loc = locale.get(lang);
|
||||
const h = loc ? have.find((x: J) => x.attributes.locale === loc) : undefined;
|
||||
const patch = h ? appleGroupLocDiff(l, { name: h.attributes.name, customAppName: h.attributes.customAppName }) : null;
|
||||
if (h && patch && loc) out.push({ id: h.id, loc, patch });
|
||||
if (h && patch && loc) out.push({ id: h.id, loc, patch, ...(isLockedLocState(h.attributes.state) ? { locked: h.attributes.state as string } : {}) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -120,7 +121,7 @@ export function driftLines(id: string, d: SubDrift): string[] {
|
|||
const av = d.availability;
|
||||
return [
|
||||
...d.attrs.text.map(x => `${id}: ${x}`), ...d.attrs.report,
|
||||
...d.locs.map(l => `${id} ${l.loc}: ${Object.keys(l.patch).join(' + ')} differ from the config`),
|
||||
...d.locs.map(l => `${id} ${l.loc}: ${Object.keys(l.patch).join(' + ')} differ from the config${l.locked ? ` - ${lockedLocNote(l.locked)}` : ''}`),
|
||||
...(av ? [`${id}: availability${av.missing.length ? ` ${av.missing.length} territories missing (${av.missing.slice(0, 5).join(', ')})` : ''}${av.newTerritories ? ' new territories off' : ''}`] : []),
|
||||
...d.intro,
|
||||
];
|
||||
|
|
@ -160,7 +161,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const heldDry: HeldPrice[] = [];
|
||||
for (const g of groups) {
|
||||
const sg = state.groups.find(x => x.referenceName === g.name);
|
||||
if (sg) for (const d of await readGroupLocDrift(c, g, sg.id, locale)) log(TAG, `group "${g.name}" ${d.loc}: ${Object.keys(d.patch).join(' + ')} differ from the config`);
|
||||
if (sg) for (const d of await readGroupLocDrift(c, g, sg.id, locale)) log(TAG, `group "${g.name}" ${d.loc}: ${Object.keys(d.patch).join(' + ')} differ from the config${d.locked ? ` - ${lockedLocNote(d.locked)}` : ''}`);
|
||||
for (const s of g.subscriptions) {
|
||||
const cur = state.subscriptions.find(x => x.productId === s.id);
|
||||
if (!cur) continue;
|
||||
|
|
@ -209,7 +210,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
if (state.groups.some(x => x.id === groupId)) {
|
||||
for (const d of await readGroupLocDrift(c, g, groupId, locale)) {
|
||||
await c.asc('PATCH', `/v1/subscriptionGroupLocalizations/${d.id}`, appleGroupLocPatchBody(d.id, d.patch));
|
||||
if (d.locked) { warn(TAG, ` group ${d.loc}: ${lockedLocNote(d.locked)}; skipped`); continue; }
|
||||
try { await c.asc('PATCH', `/v1/subscriptionGroupLocalizations/${d.id}`, appleGroupLocPatchBody(d.id, d.patch)); } catch (e) {
|
||||
if (!isUnmodifiableError(e)) throw e;
|
||||
warn(TAG, ` group ${d.loc}: ${lockedLocNote('ACTIVE')}; skipped`); continue;
|
||||
}
|
||||
log(TAG, ` group ${d.loc}: ${Object.keys(d.patch).join(' + ')} updated`);
|
||||
}
|
||||
}
|
||||
|
|
@ -242,7 +247,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
// and the period / family sharing are only reported (Apple cannot change them)
|
||||
const d = await readSubDrift(c, s, subId, locale, territories);
|
||||
if (Object.keys(d.attrs.patch).length) { await c.asc('PATCH', `/v1/subscriptions/${subId}`, appleSubPatchBody(subId, d.attrs.patch)); log(TAG, ` attributes updated: ${d.attrs.text.join(', ')}`); }
|
||||
for (const l of d.locs) { await c.asc('PATCH', `/v1/subscriptionLocalizations/${l.id}`, appleLocPatchBody(l.id, l.patch)); log(TAG, ` ${l.loc}: ${Object.keys(l.patch).join(' + ')} updated`); }
|
||||
for (const l of d.locs) {
|
||||
if (l.locked) { warn(TAG, ` ${l.loc}: ${lockedLocNote(l.locked)}; skipped`); continue; }
|
||||
try { await c.asc('PATCH', `/v1/subscriptionLocalizations/${l.id}`, appleLocPatchBody(l.id, l.patch)); } catch (e) {
|
||||
if (!isUnmodifiableError(e)) throw e;
|
||||
warn(TAG, ` ${l.loc}: ${lockedLocNote('ACTIVE')}; skipped`); continue;
|
||||
}
|
||||
log(TAG, ` ${l.loc}: ${Object.keys(l.patch).join(' + ')} updated`);
|
||||
}
|
||||
if (d.availability) { await c.asc('POST', '/v1/subscriptionAvailabilities', appleAvailabilityBody(subId, territories)); log(TAG, ` availability set again: ${territories.length} territories + new ones`); }
|
||||
for (const x of [...d.attrs.report, ...d.intro]) warn(TAG, x);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@
|
|||
"only-decreases",
|
||||
"out",
|
||||
"reconvert",
|
||||
"skip-out-of-range-regions",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
|
|
@ -174,6 +175,7 @@
|
|||
"only-decreases",
|
||||
"out",
|
||||
"reconvert",
|
||||
"skip-out-of-range-regions",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
|
|
@ -293,6 +295,7 @@
|
|||
"only-decreases",
|
||||
"out",
|
||||
"reconvert",
|
||||
"skip-out-of-range-regions",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
|
|
@ -356,6 +359,7 @@
|
|||
"only-decreases",
|
||||
"out",
|
||||
"reconvert",
|
||||
"skip-out-of-range-regions",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
|
|
@ -416,6 +420,7 @@
|
|||
"only-decreases",
|
||||
"out",
|
||||
"reconvert",
|
||||
"skip-out-of-range-regions",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
|
|
@ -624,6 +629,7 @@
|
|||
"play",
|
||||
"react-native",
|
||||
"reconvert",
|
||||
"skip-out-of-range-regions",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
|
|
@ -705,7 +711,8 @@
|
|||
],
|
||||
"play next-version-code": [],
|
||||
"play products": [
|
||||
"allow-price-changes"
|
||||
"allow-price-changes",
|
||||
"skip-out-of-range-regions"
|
||||
],
|
||||
"play release": [
|
||||
"alpha",
|
||||
|
|
@ -740,7 +747,8 @@
|
|||
],
|
||||
"play status": [
|
||||
"allow-price-changes",
|
||||
"review-sent"
|
||||
"review-sent",
|
||||
"skip-out-of-range-regions"
|
||||
],
|
||||
"play subscriptions": [
|
||||
"all",
|
||||
|
|
@ -762,6 +770,7 @@
|
|||
"out",
|
||||
"prune-languages",
|
||||
"reconvert",
|
||||
"skip-out-of-range-regions",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
|
|
@ -803,6 +812,7 @@
|
|||
"only-decreases",
|
||||
"out",
|
||||
"reconvert",
|
||||
"skip-out-of-range-regions",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
|
|
@ -1026,6 +1036,7 @@
|
|||
"react-native",
|
||||
"repair-stuck",
|
||||
"signature",
|
||||
"skip-out-of-range-regions",
|
||||
"src",
|
||||
"unity",
|
||||
"variant",
|
||||
|
|
@ -1263,6 +1274,7 @@
|
|||
"signature",
|
||||
"since",
|
||||
"size",
|
||||
"skip-out-of-range-regions",
|
||||
"sku",
|
||||
"source",
|
||||
"src",
|
||||
|
|
@ -1391,9 +1403,6 @@
|
|||
"apple game-center": [
|
||||
"storebound apple game-center [status] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"apple iap": [
|
||||
"storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]"
|
||||
],
|
||||
"apple iap-review": [
|
||||
"storebound apple iap-review [--dry]"
|
||||
],
|
||||
|
|
@ -1407,9 +1416,6 @@
|
|||
"apple next-build": [
|
||||
"storebound apple next-build -> 42"
|
||||
],
|
||||
"apple offer-codes": [
|
||||
"storebound apple offer-codes [--only id1,id2] [--codes] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"apple ppo": [
|
||||
"storebound apple ppo status [--json]",
|
||||
"storebound apple ppo create --variant b[,c] [--name \"...\"] [--traffic 30] [--dry]",
|
||||
|
|
@ -1434,9 +1440,6 @@
|
|||
"apple resubmit": [
|
||||
"storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"apple review-details": [
|
||||
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
|
||||
],
|
||||
"apple screenshots": [
|
||||
"storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]",
|
||||
"storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)"
|
||||
|
|
@ -1453,9 +1456,6 @@
|
|||
"apple submit": [
|
||||
"storebound apple submit --build <n> [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"apple subscriptions": [
|
||||
"storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes] [--allow-price-changes]"
|
||||
],
|
||||
"apple testflight": [
|
||||
"storebound apple testflight status [--json] groups, testers per group, recent builds + beta states",
|
||||
"storebound apple testflight groups list the groups and what the config would create (read-only)",
|
||||
|
|
@ -1596,9 +1596,6 @@
|
|||
"play next-version-code": [
|
||||
"storebound play next-version-code -> 43"
|
||||
],
|
||||
"play products": [
|
||||
"storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes]"
|
||||
],
|
||||
"play release": [
|
||||
"storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--dry]",
|
||||
"storebound play release --version-code 3 --production draft",
|
||||
|
|
@ -1613,9 +1610,6 @@
|
|||
"play status": [
|
||||
"storebound play status [--json] [--review-sent]"
|
||||
],
|
||||
"play subscriptions": [
|
||||
"storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]"
|
||||
],
|
||||
"play upload": [
|
||||
"storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
16
cli/lib/loc-lock.ts
Normal 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
78
cli/lib/play-limits.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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