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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue