180 lines
13 KiB
TypeScript
180 lines
13 KiB
TypeScript
// Cross-store catalog parity report (read-only on both stores):
|
|
// storebound catalog diff [--store apple|play] [--threshold 10] [--json]
|
|
// One product definition (products + subscriptions in storebound.config.ts) against what exists on App Store Connect
|
|
// and Google Play: missing and extra items, kind / billing period / level mismatches, base (US) and override prices,
|
|
// localizations, offers, and a per-region price comparison between the stores (same currency, difference over
|
|
// --threshold percent). Exit 1 when an item is missing or mismatched; price differences between the stores are
|
|
// information (stores round to their own price tiers). Pure rules: lib/catalog.ts.
|
|
// storebound catalog pull [--store apple|play] [--write] [--json]
|
|
// The live titles and descriptions (Play: + benefits) of in-app purchases and subscriptions against the listings in
|
|
// storebound.config.ts: a proposal diff per product and language. The config (TypeScript) is never rewritten; --write
|
|
// saves the proposal as .storebound/pull/catalog.json for you or your agent to merge.
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
import { appleCurrent, pages, playProduct } from './prices';
|
|
import { catalogProposal, type RemoteCatalog } from './lib/listing-pull';
|
|
import { ascClient, type J } from './apple/asc';
|
|
import { type CatalogRow, catalogDiff, fromApplePeriod, fromAppleType, regionOf, type StoreItem, wantedCatalog } from './lib/catalog';
|
|
import type { Ctx } from './lib/ctx';
|
|
import { done, log, printJson } from './lib/log';
|
|
import { fromMoney, type Money } from './lib/money';
|
|
import { listOneTimeProducts } from './play/products';
|
|
import { playClient } from './play/client';
|
|
import { appleSubPrices, basePlanId } from './lib/subscriptions';
|
|
import { textTable } from './lib/table';
|
|
|
|
const TAG = 'catalog';
|
|
|
|
export async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
|
|
const { config, root } = ctx.loaded;
|
|
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
|
const appId = await c.app();
|
|
const out: StoreItem[] = [];
|
|
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
|
for (const [i, p] of iaps.entries()) {
|
|
log(TAG, `apple ${i + 1}/${iaps.length} ${p.attributes.productId}`);
|
|
const locales = (await c.all(`/v2/inAppPurchases/${p.id}/inAppPurchaseLocalizations?limit=50`)).map((l: J) => l.attributes.locale);
|
|
const hasSchedule = !!(await c.maybe(`/v2/inAppPurchases/${p.id}/iapPriceSchedule`))?.data;
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
// today's price per territory: entries that started (or have no start), the latest start wins; manual over automatic
|
|
const cur = hasSchedule ? await appleCurrent(c, p.id, p.attributes.productId, true) : null;
|
|
const rows = (cur?.rows ?? []).filter(r => (r.startDate ?? '') <= today)
|
|
.sort((x, y) => (x.kind === 'auto' ? 0 : 1) - (y.kind === 'auto' ? 0 : 1) || (x.startDate ?? '').localeCompare(y.startDate ?? ''));
|
|
const byRegion = new Map(rows.map(r => [r.region, r] as const));
|
|
// the price schedule's base territory (alpha-3) and its price today
|
|
const base = cur?.base ? byRegion.get(regionOf(cur.base)) : undefined;
|
|
out.push({ id: p.attributes.productId, kind: fromAppleType(p.attributes.inAppPurchaseType), state: p.attributes.state, locales,
|
|
prices: [...byRegion.values()].map(r => ({ region: r.region, currency: r.currency, price: r.price })),
|
|
...(base ? { base: { region: base.region, currency: base.currency, price: base.price } } : {}) });
|
|
}
|
|
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
|
|
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) {
|
|
log(TAG, `apple subscription ${s.attributes.productId}`);
|
|
const locales = (await c.all(`/v1/subscriptions/${s.id}/subscriptionLocalizations?limit=200`)).map((l: J) => l.attributes.locale);
|
|
const prices = appleSubPrices(await pages(c, `/v1/subscriptions/${s.id}/prices?include=subscriptionPricePoint,territory&limit=200`)).filter(p => !p.startDate);
|
|
const intro = (await c.asc('GET', `/v1/subscriptions/${s.id}/introductoryOffers?limit=1`)).data?.length ? ['intro'] : [];
|
|
const promos = (await c.all(`/v1/subscriptions/${s.id}/promotionalOffers?limit=200`)).map((o: J) => o.attributes.offerCode as string);
|
|
const codes = (await c.all(`/v1/subscriptions/${s.id}/offerCodes?limit=200`)).map((o: J) => `code:${o.attributes.name}`);
|
|
out.push({ id: s.attributes.productId, kind: 'subscription', state: s.attributes.state, period: fromApplePeriod(s.attributes.subscriptionPeriod),
|
|
level: s.attributes.groupLevel, group: g.attributes.referenceName, locales, prices: prices.map(p => ({ region: regionOf(p.territory), currency: p.currency ?? '', price: p.price })),
|
|
offers: [...intro, ...promos, ...codes] });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
|
const { config, root } = ctx.loaded;
|
|
const play = await playClient(root, config.play!.packageName);
|
|
const out: StoreItem[] = [];
|
|
for (const { productId } of await listOneTimeProducts(play)) {
|
|
log(TAG, `play ${productId}`);
|
|
const p = await playProduct(play, productId);
|
|
if (!p) continue;
|
|
const opt = p.purchaseOptions?.find(o => o.purchaseOptionId === 'buy') ?? p.purchaseOptions?.[0];
|
|
out.push({ id: productId, kind: 'one_time', state: opt?.state, locales: ((p.listings as { languageCode: string }[] | undefined) ?? []).map(l => l.languageCode),
|
|
prices: (opt?.regionalPricingAndAvailabilityConfigs ?? []).filter(r => r.price && r.availability !== 'NO_LONGER_AVAILABLE').map(r => ({ region: r.regionCode, currency: r.price!.currencyCode, price: fromMoney(r.price!) })),
|
|
...((opt?.newRegionsConfig as { usdPrice?: Money } | undefined)?.usdPrice ? { usd: fromMoney((opt!.newRegionsConfig as { usdPrice: Money }).usdPrice) } : {}) });
|
|
}
|
|
let page = '';
|
|
do {
|
|
const r = await play.call<{ subscriptions?: J[]; nextPageToken?: string }>('GET', `${play.API}/subscriptions?pageSize=100${page ? '&pageToken=' + page : ''}`);
|
|
for (const s of r.subscriptions ?? []) {
|
|
log(TAG, `play subscription ${s.productId}`);
|
|
const subCfg = config.subscriptions?.flatMap(g => g.subscriptions).find(x => x.id === s.productId);
|
|
const bpId = subCfg ? basePlanId(subCfg) : undefined;
|
|
const plans: J[] = s.basePlans ?? [];
|
|
const bp = plans.find(b => b.basePlanId === bpId) ?? plans.find(b => b.autoRenewingBasePlanType?.billingPeriodDuration === subCfg?.period) ?? plans[0];
|
|
const offers = bp ? ((await play.call<{ subscriptionOffers?: { offerId: string }[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.productId)}/basePlans/${bp.basePlanId}/offers`)).subscriptionOffers ?? []).map(o => o.offerId) : [];
|
|
out.push({ id: s.productId, kind: 'subscription', state: bp?.state, period: bp?.autoRenewingBasePlanType?.billingPeriodDuration ?? bp?.prepaidBasePlanType?.billingPeriodDuration,
|
|
locales: (s.listings ?? []).map((l: J) => l.languageCode),
|
|
prices: (bp?.regionalConfigs ?? []).filter((x: J) => x.price).map((x: J) => ({ region: x.regionCode, currency: x.price.currencyCode, price: fromMoney(x.price) })), offers,
|
|
...(bp?.otherRegionsConfig?.usdPrice ? { usd: fromMoney(bp.otherRegionsConfig.usdPrice) } : {}) });
|
|
}
|
|
page = r.nextPageToken ?? '';
|
|
} while (page);
|
|
return out;
|
|
}
|
|
|
|
export async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
|
const { config, root } = ctx.loaded;
|
|
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
|
const appId = await c.app();
|
|
const out: RemoteCatalog = {};
|
|
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`))
|
|
out[p.attributes.productId] = Object.fromEntries((await c.all(`/v2/inAppPurchases/${p.id}/inAppPurchaseLocalizations?limit=50`))
|
|
.map((l: J) => [l.attributes.locale, { title: l.attributes.name ?? '', description: l.attributes.description ?? '' }]));
|
|
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`))
|
|
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`))
|
|
out[s.attributes.productId] = Object.fromEntries((await c.all(`/v1/subscriptions/${s.id}/subscriptionLocalizations?limit=200`))
|
|
.map((l: J) => [l.attributes.locale, { title: l.attributes.name ?? '', description: l.attributes.description ?? '' }]));
|
|
log(TAG, `apple: texts of ${Object.keys(out).length} product(s)`);
|
|
return out;
|
|
}
|
|
|
|
export async function playTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
|
const { config, root } = ctx.loaded;
|
|
const play = await playClient(root, config.play!.packageName);
|
|
const out: RemoteCatalog = {};
|
|
for (const { productId } of await listOneTimeProducts(play)) {
|
|
const p = await playProduct(play, productId);
|
|
out[productId] = Object.fromEntries(((p?.listings as { languageCode: string; title?: string; description?: string }[] | undefined) ?? []).map(l => [l.languageCode, { title: l.title ?? '', description: l.description ?? '' }]));
|
|
}
|
|
let page = '';
|
|
do {
|
|
const r = await play.call<{ subscriptions?: J[]; nextPageToken?: string }>('GET', `${play.API}/subscriptions?pageSize=100${page ? '&pageToken=' + page : ''}`);
|
|
for (const s of r.subscriptions ?? []) out[s.productId] = Object.fromEntries((s.listings ?? []).map((l: J) => [l.languageCode, { title: l.title ?? '', description: l.description ?? '', ...(l.benefits?.length ? { benefits: l.benefits } : {}) }]));
|
|
page = r.nextPageToken ?? '';
|
|
} while (page);
|
|
log(TAG, `play: texts of ${Object.keys(out).length} product(s)`);
|
|
return out;
|
|
}
|
|
|
|
async function pull(ctx: Ctx): Promise<void> {
|
|
const { config, resolve } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const store = a.get('store');
|
|
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
|
const apple = (!store || store === 'apple') && config.apple ? await appleTexts(ctx) : undefined;
|
|
const play = (!store || store === 'play') && config.play ? await playTexts(ctx) : undefined;
|
|
const p = catalogProposal(config, { apple, play });
|
|
const file = resolve('.storebound/pull/catalog.json');
|
|
if (a.has('json')) printJson({ file, write: a.has('write') && !a.has('dry'), ...p });
|
|
else {
|
|
if (p.changes.length) console.log(textTable(['product', 'lang', 'store', 'field', 'config', 'store text'], p.changes.map(c => [c.id, c.lang, c.store, c.field, (c.from ?? '(missing)').slice(0, 40), c.to.slice(0, 40)])));
|
|
if (p.unknown.length) log(TAG, `on the store but not in the config: ${p.unknown.join(', ')}`);
|
|
if (p.unmapped.length) log(TAG, `store languages with no entry in languages: ${p.unmapped.join(', ')}`);
|
|
}
|
|
if (!p.changes.length) { done(TAG, 'the config listings match the stores'); return; }
|
|
if (!a.has('write') || a.has('dry')) { done(TAG, `${p.changes.length} difference(s); proposal not written (add --write for ${file})`); return; }
|
|
mkdirSync(dirname(file), { recursive: true });
|
|
writeFileSync(file, JSON.stringify({ note: 'storebound catalog pull: store texts that differ from storebound.config.ts. Merge them into products[].listings / subscriptions[].subscriptions[].listings.', listings: p.listings, changes: p.changes }, null, 2) + '\n');
|
|
done(TAG, `proposal written: ${file} (${p.changes.length} difference(s)); storebound.config.ts is unchanged`);
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config } = ctx.loaded;
|
|
const a = ctx.args;
|
|
if (a._[0] === 'pull') return pull(ctx);
|
|
if (a._[0] !== 'diff') throw new Error('usage: storebound catalog diff [--store apple|play] [--threshold 10] [--json] | pull [--store apple|play] [--write] [--json]');
|
|
const store = a.get('store');
|
|
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
|
const want = wantedCatalog(config);
|
|
const apple = (!store || store === 'apple') && config.apple ? await appleItems(ctx) : null;
|
|
const play = (!store || store === 'play') && config.play ? await playItems(ctx) : null;
|
|
const { rows, parity } = catalogDiff(want, { apple, play }, { threshold: a.num('threshold', 10) / 100 });
|
|
const errors = rows.filter(r => r.level === 'error').length;
|
|
if (a.has('json')) printJson({ errors, rows, parity });
|
|
else {
|
|
console.log(formatRows(rows));
|
|
if (parity.length) console.log(`\nprice differences between the stores (same currency, over ${a.num('threshold', 10)} %):\n` +
|
|
textTable(['product', 'region', 'currency', 'apple', 'play', 'diff %'], parity.map(p => [p.id, p.region, p.currency, p.apple.toFixed(2), p.play.toFixed(2), p.diffPct.toFixed(1)])));
|
|
}
|
|
done(TAG, `${want.length} catalog items: ${errors} mismatch(es), ${rows.filter(r => r.level === 'warn').length} warning(s), ${parity.length} price difference(s) between the stores`);
|
|
if (errors) process.exitCode = 1;
|
|
}
|
|
|
|
export function formatRows(rows: CatalogRow[]): string {
|
|
const tag = { error: 'ERROR', warn: 'warn ', ok: 'ok ' } as const;
|
|
return rows.map(r => `${tag[r.level]} [${r.store}] ${r.id}: ${r.msg}`).join('\n') || 'no catalog items';
|
|
}
|