// 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 } 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'; async function appleItems(ctx: Ctx): Promise { 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 rows = hasSchedule ? (await appleCurrent(c, p.id, p.attributes.productId, true)).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)); 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 })) }); } 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, locales, prices: prices.map(p => ({ region: regionOf(p.territory), currency: p.currency ?? '', price: p.price })), offers: [...intro, ...promos, ...codes] }); } } return out; } async function playItems(ctx: Ctx): Promise { 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!) })) }); } 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 }); } page = r.nextPageToken ?? ''; } while (page); return out; } async function appleTexts(ctx: Ctx): Promise { 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; } async function playTexts(ctx: Ctx): Promise { 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 { 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 { 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'; }