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

@ -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. */

View file

@ -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);
}