storebound/cli/catalog.ts

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';
}