Price sheet: storebound prices export | import (CSV of product x store x region)

export reads the current App Store manual (and with --with-automatic the equalized) prices and the Play purchase
option regional prices into one CSV. import parses and checks the sheet, prints a diff table (change %), and after a
confirmation writes one App Store price schedule per changed product (base territory + every manual price, future-dated
changes via start_date, price point ids looked up per territory) and the Play regional configs (CSV over current,
convertRegionPrices for regions without a price, --reconvert). MCP: prices_diff (read-only), prices_export; applying
stays a terminal command. store-iap-setup skill: price sheet section.
This commit is contained in:
CRTkafa 2026-10-10 23:18:43 +08:00
parent c99febc45b
commit 104cd79117
8 changed files with 550 additions and 1 deletions

View file

@ -18,6 +18,7 @@ const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'check': [() => import('./check'), 'validate config, copy files, data safety answers and products (offline); --aso keyword audit'], 'check': [() => import('./check'), 'validate config, copy files, data safety answers and products (offline); --aso keyword audit'],
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'], 'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'], 'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'],
'prices': [() => import('./prices'), 'export | import a CSV of product x store x region prices (diff first, asks before writing; Apple future-dated changes)'],
'reviews': [() => import('./reviews'), 'list | draft (local reply drafts in .storebound/replies) | send (approved drafts only, asks first)'], 'reviews': [() => import('./reviews'), 'list | draft (local reply drafts in .storebound/replies) | send (approved drafts only, asks first)'],
'whats-new': [() => import('./whats-new'), 'draft player-facing release notes from git log into the listing files (--since <tag>)'], 'whats-new': [() => import('./whats-new'), 'draft player-facing release notes from git log into the listing files (--since <tag>)'],
'play products': [() => import('./play/products'), 'one-time products: create / update, regional prices, activate'], 'play products': [() => import('./play/products'), 'one-time products: create / update, regional prices, activate'],

View file

@ -288,6 +288,13 @@ export interface ShipConfig {
testflight?: TfConfig; testflight?: TfConfig;
}; };
products?: Product[]; products?: Product[];
/** Price sheet for `storebound prices export | import` (product x store x region). */
prices?: {
/** CSV path. Default store/prices.csv. */
csv?: string;
/** App Store base territory of every price schedule (alpha-3). Default: the current one, else USA. */
baseTerritory?: string;
};
dataSafety?: DataSafety; dataSafety?: DataSafety;
/** Ads: `validate` checks that <app.website>/app-ads.txt is reachable and contains every line listed here. */ /** Ads: `validate` checks that <app.website>/app-ads.txt is reachable and contains every line listed here. */
ads?: { appAdsTxt?: string[] }; ads?: { appAdsTxt?: string[] };
@ -359,6 +366,7 @@ export function validateConfig(c: unknown): string[] {
if (!(o.price > 0)) e.push(`${at}.overrides: price must be positive`); if (!(o.price > 0)) e.push(`${at}.overrides: price must be positive`);
} }
} }
if (cfg.prices?.baseTerritory && !/^[A-Z]{3}$/.test(cfg.prices.baseTerritory)) e.push('prices.baseTerritory: App Store territory, alpha-3 (USA)');
if (cfg.dataSafety) { if (cfg.dataSafety) {
for (const [t, u] of Object.entries(cfg.dataSafety.data ?? {})) { for (const [t, u] of Object.entries(cfg.dataSafety.data ?? {})) {
if (!/^PSL_[A-Z_]+$/.test(t)) e.push(`dataSafety.data.${t}: use the Play type id (PSL_DEVICE_ID, ...)`); if (!/^PSL_[A-Z_]+$/.test(t)) e.push(`dataSafety.data.${t}: use the Play type id (PSL_DEVICE_ID, ...)`);

227
cli/lib/prices.ts Normal file
View file

@ -0,0 +1,227 @@
// Prices of in-app products as one CSV (product x store x region), as pure parts: parsing and checking the sheet,
// reading the current store prices into the same rows, the diff, and the request bodies `storebound prices import`
// sends after a confirmation.
// Sources (checked October 2026):
// Apple: price points GET /v2/inAppPurchases/{id}/pricePoints; schedule POST /v1/inAppPurchasePriceSchedules with
// baseTerritory + every manual price (startDate null = current, YYYY-MM-DD = scheduled change); a POST sends the
// whole manual schedule (Apple's "Update In-App Purchase prices" example sends the current and the future price
// together). https://developer.apple.com/documentation/appstoreconnectapi/managing-in-app-purchases
// Territories without a manual price follow the base territory (Apple equalizes them, "automatic prices").
// Google Play: monetization.onetimeproducts PATCH (updateMask, regionsVersion.version, allowMissing) with
// purchaseOptions[].regionalPricingAndAvailabilityConfigs {regionCode, price Money in the region currency,
// availability}; pricing:convertRegionPrices for regions without a price.
// https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.onetimeproducts
// Play has no scheduled prices: start_date is Apple only.
import { parseCsvRecords, toCsv } from './csv';
import { ALPHA3, fromMoney, type Money, type RegionConfig, toMoney } from './money';
import { textTable } from './table';
export interface PriceRow {
product: string;
store: 'apple' | 'play';
/** ISO 3166-1 alpha-2 (Play region; Apple territory is looked up, or given as `territory`). */
region: string;
territory?: string;
currency: string;
price: number;
/** Apple only: the date a scheduled price starts (YYYY-MM-DD); empty = the current price. */
startDate?: string;
/** Exported Apple prices: "manual" (set by you) or "auto" (equalized from the base territory, read-only). */
kind?: 'manual' | 'auto';
/** Apple price point id when read from the store (not in the CSV). */
pointId?: string;
}
export const CSV_HEAD = ['product', 'store', 'region', 'currency', 'price', 'start_date', 'kind'];
const ALPHA2 = Object.fromEntries(Object.entries(ALPHA3).map(([a2, a3]) => [a3, a2]));
export const alpha2 = (territory: string): string => ALPHA2[territory] ?? territory;
/** CSV -> rows. `store` may be apple, play or both (both = one row per store). Throws with every problem at once. */
export function parsePriceCsv(text: string, o: { products?: string[]; today?: string } = {}): PriceRow[] {
const out: PriceRow[] = [];
const errors: string[] = [];
const seen = new Set<string>();
const today = o.today ?? new Date().toISOString().slice(0, 10);
for (const [i, r] of parseCsvRecords(text).entries()) {
const at = `row ${i + 2}`;
const product = r.product ?? '';
const stores = (r.store || 'both').toLowerCase();
const region = (r.region ?? '').toUpperCase();
const currency = (r.currency ?? '').toUpperCase();
const price = Number((r.price ?? '').replace(',', '.'));
const startDate = r.start_date || undefined;
const kind = r.kind === 'auto' ? 'auto' : r.kind === 'manual' || !r.kind ? 'manual' : undefined;
if (!/^[A-Za-z0-9._]+$/.test(product)) { errors.push(`${at}: product "${product}"`); continue; }
if (o.products && !o.products.includes(product)) errors.push(`${at}: ${product} is not in products[] of the config`);
if (!['apple', 'play', 'both'].includes(stores)) errors.push(`${at}: store must be apple, play or both`);
if (!/^[A-Z]{2}$/.test(region)) errors.push(`${at}: region "${r.region}" must be ISO alpha-2 (US, TR, ...)`);
if (!/^[A-Z]{3}$/.test(currency)) errors.push(`${at}: currency "${r.currency}" must be ISO 4217 (USD, TRY, ...)`);
if (!(price > 0)) errors.push(`${at}: price must be a positive number`);
if (!kind) errors.push(`${at}: kind must be manual or auto`);
if (startDate !== undefined) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) errors.push(`${at}: start_date must be YYYY-MM-DD`);
else if (startDate <= today) errors.push(`${at}: start_date ${startDate} is not in the future (leave it empty for the current price)`);
if (stores !== 'apple') errors.push(`${at}: start_date is App Store only (Play has no scheduled prices): set store to apple`);
}
for (const store of stores === 'both' ? ['apple', 'play'] as const : [stores as 'apple' | 'play']) {
const key = `${product}|${store}|${region}|${startDate ?? ''}`;
if (seen.has(key)) errors.push(`${at}: ${product} ${store} ${region}${startDate ? ` ${startDate}` : ''} listed twice`);
seen.add(key);
if (store === 'apple' && !ALPHA3[region]) errors.push(`${at}: no App Store territory known for ${region}`);
out.push({ product, store, region, ...(store === 'apple' ? { territory: ALPHA3[region] } : {}), currency, price, ...(startDate ? { startDate } : {}), ...(kind === 'auto' ? { kind } : {}) });
}
}
if (errors.length) throw new Error(`prices CSV: ${errors.join('; ')}`);
return out;
}
export function pricesCsv(rows: PriceRow[]): string {
const sorted = [...rows].sort((a, b) => a.product.localeCompare(b.product) || a.store.localeCompare(b.store) || a.region.localeCompare(b.region) || (a.startDate ?? '').localeCompare(b.startDate ?? ''));
return toCsv([CSV_HEAD, ...sorted.map(r => [r.product, r.store, r.region, r.currency, fmt(r.price), r.startDate ?? '', r.kind ?? 'manual'])]);
}
const fmt = (n: number) => (Math.round(n * 100) / 100).toFixed(2).replace(/\.00$/, '');
// ---- reading the stores into rows ----
interface ApplePricesResp {
data?: { attributes?: { startDate?: string | null; endDate?: string | null }; relationships?: { inAppPurchasePricePoint?: { data?: { id: string } }; territory?: { data?: { id: string } } } }[];
included?: { type: string; id: string; attributes?: { customerPrice?: string; currency?: string } }[];
}
/** GET /v1/inAppPurchasePriceSchedules/{id}/manualPrices (or automaticPrices) ?include=inAppPurchasePricePoint,territory -> rows. */
export function applePriceRows(product: string, resp: ApplePricesResp, kind: 'manual' | 'auto' = 'manual'): PriceRow[] {
const points = new Map((resp.included ?? []).filter(x => x.type === 'inAppPurchasePricePoints').map(x => [x.id, x.attributes?.customerPrice] as const));
const curr = new Map((resp.included ?? []).filter(x => x.type === 'territories').map(x => [x.id, x.attributes?.currency] as const));
const out: PriceRow[] = [];
for (const p of resp.data ?? []) {
const territory = p.relationships?.territory?.data?.id;
const price = Number(points.get(p.relationships?.inAppPurchasePricePoint?.data?.id ?? '') ?? NaN);
if (!territory || Number.isNaN(price)) continue;
out.push({ product, store: 'apple', region: alpha2(territory), territory, currency: curr.get(territory) ?? '', price, pointId: p.relationships?.inAppPurchasePricePoint?.data?.id, ...(p.attributes?.startDate ? { startDate: p.attributes.startDate } : {}), ...(kind === 'auto' ? { kind } : {}) });
}
return out;
}
export interface PlayProduct {
productId: string;
purchaseOptions?: { purchaseOptionId: string; state?: string; regionalPricingAndAvailabilityConfigs?: { regionCode: string; price?: Money; availability?: string }[]; newRegionsConfig?: unknown; [k: string]: unknown }[];
[k: string]: unknown;
}
export function playPriceRows(p: PlayProduct, optionId = 'buy'): PriceRow[] {
const o = p.purchaseOptions?.find(x => x.purchaseOptionId === optionId) ?? p.purchaseOptions?.[0];
return (o?.regionalPricingAndAvailabilityConfigs ?? []).filter(c => c.price && c.availability !== 'NO_LONGER_AVAILABLE')
.map(c => ({ product: p.productId, store: 'play' as const, region: c.regionCode, currency: c.price!.currencyCode, price: fromMoney(c.price!) }));
}
// ---- diff ----
export interface DiffRow { product: string; store: 'apple' | 'play'; region: string; startDate?: string; currency: string; from: number | null; to: number; change: 'new' | 'change' | 'same' }
const key = (r: Pick<PriceRow, 'product' | 'store' | 'region' | 'startDate'>) => `${r.product}|${r.store}|${r.region}|${r.startDate ?? ''}`;
const cents = (n: number) => Math.round(n * 100);
/** Wanted rows (auto rows ignored) vs current rows. */
export function diffPrices(current: PriceRow[], wanted: PriceRow[]): DiffRow[] {
const cur = new Map(current.filter(r => r.kind !== 'auto').map(r => [key(r), r] as const));
const auto = new Map(current.filter(r => r.kind === 'auto').map(r => [key(r), r] as const));
return wanted.filter(w => w.kind !== 'auto').map(w => {
const c = cur.get(key(w));
// An equalized (auto) Apple price becomes a manual one: "new", shown with the equalized price as "current".
const from = c ? c.price : auto.get(key(w))?.price ?? null;
const change: DiffRow['change'] = !c ? 'new' : cents(c.price) === cents(w.price) && (!c.currency || c.currency === w.currency) ? 'same' : 'change';
return { product: w.product, store: w.store, region: w.region, ...(w.startDate ? { startDate: w.startDate } : {}), currency: w.currency, from, to: w.price, change };
});
}
export function diffTable(d: DiffRow[], o: { all?: boolean } = {}): string {
const rows = d.filter(x => o.all || x.change !== 'same').map(x => [x.product, x.store, x.region, x.startDate ?? 'now', x.currency, x.from === null ? '-' : fmt(x.from), fmt(x.to),
x.from ? `${x.to >= x.from ? '+' : ''}${(((x.to - x.from) / x.from) * 100).toFixed(1)}%` : x.change]);
return rows.length ? textTable(['product', 'store', 'region', 'from', 'currency', 'current', 'new', 'change'], rows) : 'no price changes';
}
// ---- Play: the regional configs to PATCH ----
/**
* Final regional configs of one product: CSV prices on top of the current ones; regions Play sells in that the product
* has no price for yet come from convertRegionPrices (USD base); `reconvert` also resets every region the CSV does not
* list to the converted price. Throws on a CSV region Play does not sell in or a currency that does not match it.
*/
export function playRegions(current: { regionCode: string; price?: Money; availability?: string }[], wanted: PriceRow[], converted: Record<string, { regionCode: string; price: Money }> | null, o: { reconvert?: boolean } = {}): RegionConfig[] {
const conv = new Map(Object.values(converted ?? {}).map(r => [r.regionCode, r.price] as const));
const cur = new Map(current.filter(c => c.price).map(c => [c.regionCode, c.price!] as const));
const out = new Map<string, Money>();
for (const code of new Set([...cur.keys(), ...conv.keys()])) {
const c = cur.get(code), v = conv.get(code);
const pick = o.reconvert ? v ?? c : c ?? v;
if (pick) out.set(code, pick);
}
for (const w of wanted) {
const known = cur.get(w.region) ?? conv.get(w.region);
if (!known && converted) throw new Error(`${w.product}: Play does not sell in region ${w.region}`);
if (known && known.currencyCode !== w.currency) throw new Error(`${w.product} ${w.region}: Play prices this region in ${known.currencyCode}, the CSV says ${w.currency}`);
out.set(w.region, toMoney(w.currency, w.price));
}
return [...out.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([regionCode, price]) => ({ regionCode, availability: 'AVAILABLE' as const, price }));
}
/** The product with its purchase option's regional configs replaced (output-only `state` dropped) for the PATCH. */
export function playPatchBody(p: PlayProduct, regions: RegionConfig[], optionId = 'buy'): PlayProduct {
return { ...p, purchaseOptions: (p.purchaseOptions ?? []).map(o => {
const { state: _s, ...rest } = o;
return o.purchaseOptionId === optionId ? { ...rest, regionalPricingAndAvailabilityConfigs: regions } : rest;
}) };
}
// ---- Apple: the manual schedule to POST ----
export interface ScheduleEntry { territory: string; price: number; startDate: string | null }
/**
* The full manual schedule after the import: territories the CSV names get exactly the CSV's entries (a row without
* start_date = the current price; dated rows = scheduled changes; dated rows alone keep the current price), every
* other territory keeps its entries. The base territory must end up with a current price.
*/
export function appleSchedule(current: PriceRow[], wanted: PriceRow[], base: string): ScheduleEntry[] {
const byTerr = new Map<string, ScheduleEntry[]>();
for (const c of current.filter(r => r.kind !== 'auto')) byTerr.set(c.territory!, [...(byTerr.get(c.territory!) ?? []), { territory: c.territory!, price: c.price, startDate: c.startDate ?? null }]);
const touched = new Map<string, PriceRow[]>();
for (const w of wanted.filter(r => r.kind !== 'auto')) touched.set(w.territory!, [...(touched.get(w.territory!) ?? []), w]);
for (const [t, rows] of touched) {
const now = rows.find(r => !r.startDate);
const keepNow = (byTerr.get(t) ?? []).find(e => e.startDate === null);
const entries: ScheduleEntry[] = [];
if (now) entries.push({ territory: t, price: now.price, startDate: null });
else if (keepNow) entries.push(keepNow);
for (const r of rows.filter(x => x.startDate).sort((a, b) => a.startDate!.localeCompare(b.startDate!))) entries.push({ territory: t, price: r.price, startDate: r.startDate! });
byTerr.set(t, entries);
}
if (!(byTerr.get(base) ?? []).some(e => e.startDate === null)) throw new Error(`base territory ${base} needs a current price (a row without start_date)`);
return [...byTerr.values()].flat().sort((a, b) => (a.territory === base ? -1 : b.territory === base ? 1 : a.territory.localeCompare(b.territory)) || (a.startDate ?? '').localeCompare(b.startDate ?? ''));
}
/** POST /v1/inAppPurchasePriceSchedules body. `points` maps "territory|price" to the price point id. */
export function appleScheduleBody(iapId: string, base: string, entries: ScheduleEntry[], points: Map<string, string>): Record<string, unknown> {
const ids = entries.map((_, i) => `\${p${i}}`);
return {
data: { type: 'inAppPurchasePriceSchedules', relationships: {
inAppPurchase: { data: { type: 'inAppPurchases', id: iapId } },
baseTerritory: { data: { type: 'territories', id: base } },
manualPrices: { data: ids.map(id => ({ type: 'inAppPurchasePrices', id })) } } },
included: entries.map((e, i) => {
const point = points.get(`${e.territory}|${cents(e.price)}`);
if (!point) throw new Error(`no price point id for ${e.territory} ${e.price}`);
return { type: 'inAppPurchasePrices', id: ids[i], attributes: { startDate: e.startDate },
relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id: iapId } }, inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: point } } } };
}),
};
}
/** Same schedule (ignoring order)? Then nothing is sent. */
export function sameSchedule(a: ScheduleEntry[], b: ScheduleEntry[]): boolean {
const k = (x: ScheduleEntry[]) => x.map(e => `${e.territory}|${cents(e.price)}|${e.startDate ?? ''}`).sort().join(';');
return k(a) === k(b);
}
export const pointKey = (territory: string, price: number | string): string => `${territory}|${cents(Number(price))}`;

View file

@ -60,6 +60,14 @@ export const TOOLS: ToolDef[] = [
props: { store: { type: 'string', enum: ['apple', 'play'], description: 'Default: both' }, days: { type: 'integer', minimum: 1, maximum: 365, default: 14 }, props: { store: { type: 'string', enum: ['apple', 'play'], description: 'Default: both' }, days: { type: 'integer', minimum: 1, maximum: 365, default: 14 },
rating: { type: 'string', pattern: '^[1-5](,[1-5])*$', description: 'Only these star ratings, e.g. 1,2,3' } }, rating: { type: 'string', pattern: '^[1-5](,[1-5])*$', description: 'Only these star ratings, e.g. 1,2,3' } },
args: i => [...opt('store', i.store), ...opt('days', i.days), ...opt('rating', i.rating)] }, args: i => [...opt('store', i.store), ...opt('days', i.days), ...opt('rating', i.rating)] },
{ name: 'prices_diff', title: 'Price sheet diff', command: ['prices', 'import'], write: false, network: true,
description: 'Read-only: compare the prices CSV (product, store, region, currency, price, start_date) with the current App Store and Google Play prices and print the diff table. Applying it is an owner-only terminal command (storebound prices import, asks first).',
props: { csv: { type: 'string', description: 'CSV path (default prices.csv in the config, else store/prices.csv)' }, store: { type: 'string', enum: ['apple', 'play'] }, only, all: { type: 'boolean', default: false, description: 'Also list unchanged rows' } },
args: i => [...opt('csv', i.csv), ...opt('store', i.store), ...opt('only', i.only), ...flag('all', i.all), '--dry'] },
{ name: 'prices_export', title: 'Export prices to CSV', command: ['prices', 'export'], write: true, network: true, idempotent: true,
description: 'Read the current prices of every product on both stores and write them as the prices CSV (local file only; Apple equalized prices with with_automatic, marked kind=auto). dry_run prints the CSV.',
props: { out: { type: 'string', description: 'CSV path' }, store: { type: 'string', enum: ['apple', 'play'] }, only, with_automatic: { type: 'boolean', default: false } },
args: i => [...opt('out', i.out), ...opt('store', i.store), ...opt('only', i.only), ...flag('with-automatic', i.with_automatic)] },
{ name: 'apple_status', title: 'App Store status', command: ['apple', 'status'], write: false, network: true, { name: 'apple_status', title: 'App Store status', command: ['apple', 'status'], write: false, network: true,
description: 'Read-only App Store Connect overview: versions and their states, recent builds, in-app purchases, review submissions.' }, description: 'Read-only App Store Connect overview: versions and their states, recent builds, in-app purchases, review submissions.' },
{ name: 'apple_next_build', title: 'Next iOS build number', command: ['apple', 'next-build'], write: false, network: true, { name: 'apple_next_build', title: 'Next iOS build number', command: ['apple', 'next-build'], write: false, network: true,

143
cli/prices.ts Normal file
View file

@ -0,0 +1,143 @@
// In-app product prices for many products and regions from one CSV (both stores).
// storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--dry]
// storebound prices import [--csv store/prices.csv] [--store apple|play] [--only ids] [--reconvert] [--all] [--dry] [--yes]
// CSV columns: product, store (apple | play | both), region (ISO alpha-2), currency, price, start_date (App Store
// only, a future YYYY-MM-DD = scheduled change), kind (manual | auto; auto rows are Apple's equalized prices, exported
// for reference and ignored on import).
// import: reads the current prices, prints the diff table, and (not --dry) asks before writing. Apple: one new price
// schedule per changed product (base territory + every manual price; territories without one are equalized by Apple;
// an increase cannot be undone once it starts). Play: the purchase option's regional prices are replaced: CSV regions
// on top of the current ones; regions without a price get convertRegionPrices from the USD base (CSV US row, else the
// config's usd); --reconvert resets every region the CSV does not list to Play's converted price. Pure parts: lib/prices.ts.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { ascClient, type Asc, type J } from './apple/asc';
import type { Ctx } from './lib/ctx';
import { confirm, need } from './lib/ctx';
import { done, log, step, warn } from './lib/log';
import { toMoney } from './lib/money';
import {
appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, type PlayProduct, playPatchBody, playPriceRows, playRegions,
pointKey, type PriceRow, pricesCsv, sameSchedule,
} from './lib/prices';
import { playClient, type PlayClient } from './play/client';
const TAG = 'prices';
async function iapId(c: Asc, appId: string, productId: string): Promise<string | null> {
return (await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(productId)}`)).data?.[0]?.id ?? null;
}
async function appleCurrent(c: Asc, id: string, product: string, withAuto: boolean): Promise<{ rows: PriceRow[]; base: string | null }> {
const q = 'include=inAppPurchasePricePoint,territory&limit=200';
const rows = applePriceRows(product, await pages(c, `/v1/inAppPurchasePriceSchedules/${id}/manualPrices?${q}`));
if (withAuto) rows.push(...applePriceRows(product, await pages(c, `/v1/inAppPurchasePriceSchedules/${id}/automaticPrices?${q}`), 'auto'));
const base = (await c.maybe(`/v1/inAppPurchasePriceSchedules/${id}/baseTerritory`))?.data?.id ?? null;
return { rows, base };
}
/** data + included of every page of a paged GET (c.all keeps only data). */
async function pages(c: Asc, path: string): Promise<{ data: J[]; included: J[] }> {
const out = { data: [] as J[], included: [] as J[] };
let url: string | null = path;
while (url) { const page: J = await c.asc('GET', url); out.data.push(...(page.data ?? [])); out.included.push(...(page.included ?? [])); url = page.links?.next ?? null; }
return out;
}
async function playProduct(play: PlayClient, id: string): Promise<PlayProduct | null> {
try { return await play.call<PlayProduct>('GET', `${play.API}/oneTimeProducts/${encodeURIComponent(id)}`); }
catch (e) { if (/-> 404/.test(String(e))) return null; throw e; }
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
const sub = a._[0];
if (sub !== 'export' && sub !== 'import') throw new Error('usage: storebound prices export | import [--csv store/prices.csv] [--dry]');
const store = a.get('store');
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
const useApple = (!store || store === 'apple') && !!config.apple;
const usePlay = (!store || store === 'play') && !!config.play;
const only = a.list('only');
const products = need(config.products, 'products').filter(p => !only.length || only.includes(p.id));
const csvPath = resolve(a.get(sub === 'export' ? 'out' : 'csv') ?? config.prices?.csv ?? 'store/prices.csv');
const dry = a.has('dry');
const c = useApple ? await ascClient(root, config.apple!.bundleId, config.apple?.appId) : null;
const appId = c ? await c.app() : '';
const play = usePlay ? await playClient(root, config.play!.packageName) : null;
// current state of every product on the chosen stores
const current: PriceRow[] = [];
const iaps = new Map<string, { id: string; base: string | null }>();
const playProducts = new Map<string, PlayProduct>();
for (const [i, p] of products.entries()) {
log(TAG, `${step(i + 1, products.length, p.id)}: reading current prices`);
if (c) {
const id = await iapId(c, appId, p.id);
if (!id) warn(TAG, `${p.id}: not on App Store Connect (storebound apple iap creates it)`);
else { const cur = await appleCurrent(c, id, p.id, sub === 'export' && a.has('with-automatic')); current.push(...cur.rows); iaps.set(p.id, { id, base: cur.base }); }
}
if (play) {
const pp = await playProduct(play, p.id);
if (!pp) warn(TAG, `${p.id}: not on Google Play (storebound play products creates it)`);
else { current.push(...playPriceRows(pp)); playProducts.set(p.id, pp); }
}
}
if (sub === 'export') {
const csv = pricesCsv(current);
if (dry) { console.log(csv); done(TAG, `dry run: would write ${current.length} rows to ${csvPath}`); return; }
writeFileSync(csvPath, csv);
done(TAG, `${current.length} rows -> ${csvPath} (edit, then storebound prices import --dry)`);
return;
}
if (!existsSync(csvPath)) throw new Error(`no prices CSV at ${csvPath} (storebound prices export writes one)`);
const wantedAll = parsePriceCsv(readFileSync(csvPath, 'utf8'), { products: config.products!.map(p => p.id) })
.filter(r => products.some(p => p.id === r.product) && ((r.store === 'apple' && useApple) || (r.store === 'play' && usePlay)));
const diff = diffPrices(current, wantedAll);
console.log(diffTable(diff, { all: a.has('all') }));
const changed = new Set(diff.filter(d => d.change !== 'same').map(d => `${d.product}|${d.store}`));
const reconvert = a.has('reconvert');
if (reconvert && usePlay) for (const p of products) if (playProducts.has(p.id)) changed.add(`${p.id}|play`);
if (!changed.size) { done(TAG, 'prices up to date'); return; }
log(TAG, `${changed.size} product / store pair(s) to update`);
if (dry) { done(TAG, 'dry run: nothing written'); return; }
if (!(await confirm(ctx, `Write the new prices for ${changed.size} product / store pair(s)? Players see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`))) return;
for (const p of products) {
const wanted = wantedAll.filter(r => r.product === p.id);
if (c && changed.has(`${p.id}|apple`)) {
const iap = iaps.get(p.id);
if (!iap) { warn(TAG, `${p.id}: no App Store product, skipped`); continue; }
const cur = current.filter(r => r.product === p.id && r.store === 'apple');
const base = config.prices?.baseTerritory ?? iap.base ?? 'USA';
const entries = appleSchedule(cur, wanted.filter(r => r.store === 'apple'), base);
if (sameSchedule(entries, cur.filter(r => r.kind !== 'auto').map(r => ({ territory: r.territory!, price: r.price, startDate: r.startDate ?? null })))) { log(TAG, `${p.id} apple: schedule unchanged`); }
else {
const points = new Map(cur.filter(r => r.pointId).map(r => [pointKey(r.territory!, r.price), r.pointId!] as const));
for (const t of new Set(entries.filter(e => !points.has(pointKey(e.territory, e.price))).map(e => e.territory))) {
for (const pp of await c.all(`/v2/inAppPurchases/${iap.id}/pricePoints?filter[territory]=${t}&limit=8000`)) points.set(pointKey(t, pp.attributes.customerPrice), pp.id);
}
const missing = entries.filter(e => !points.has(pointKey(e.territory, e.price)));
if (missing.length) throw new Error(`${p.id}: no App Store price point for ${missing.map(e => `${e.territory} ${e.price}`).join(', ')} (Apple only takes its own price points; storebound prices export --with-automatic shows the equalized ones)`);
await c.asc('POST', '/v1/inAppPurchasePriceSchedules', appleScheduleBody(iap.id, base, entries, points));
log(TAG, `${p.id} apple: schedule set (${entries.length} manual price(s), base ${base})`);
}
}
if (play && changed.has(`${p.id}|play`)) {
const pp = playProducts.get(p.id);
if (!pp) { warn(TAG, `${p.id}: no Play product, skipped`); continue; }
const usd = wanted.find(r => r.store === 'play' && r.region === 'US')?.price ?? p.usd;
const conv = await play.call<{ convertedRegionPrices?: Record<string, { regionCode: string; price: { currencyCode: string; units: string; nanos?: number } }>; regionVersion?: { version: string } }>(
'POST', `${play.API}/pricing:convertRegionPrices`, { price: toMoney('USD', usd) });
const opt = pp.purchaseOptions?.find(o => o.purchaseOptionId === 'buy') ?? pp.purchaseOptions?.[0];
if (!opt) { warn(TAG, `${p.id}: Play product has no purchase option, skipped`); continue; }
const regions = playRegions(opt.regionalPricingAndAvailabilityConfigs ?? [], wanted.filter(r => r.store === 'play'), conv.convertedRegionPrices ?? null, { reconvert });
const v = encodeURIComponent(conv.regionVersion?.version ?? '2022/02');
await play.call('PATCH', `${play.API}/onetimeproducts/${encodeURIComponent(p.id)}?regionsVersion.version=${v}&updateMask=purchaseOptions`, playPatchBody(pp, regions, opt.purchaseOptionId));
log(TAG, `${p.id} play: ${regions.length} regions set`);
}
}
done(TAG, 'prices imported (storebound prices export shows the stores\' view)');
}

View file

@ -1,6 +1,6 @@
--- ---
name: store-iap-setup name: store-iap-setup
description: Create and price in-app purchases (consumables, non-consumables like "remove ads", coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - regional prices, local overrides, activation, review screenshots, submitting first IAPs with a version, license/sandbox testers. Use when the user sets up, prices, updates or tests in-app products/IAPs, or hits Play onetimeproducts / inappproducts 403 or App Store IAP submission errors. description: Create and price in-app purchases (consumables, non-consumables like "remove ads", coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - regional prices, local overrides, a price sheet CSV for many products and regions (diff first, future-dated App Store changes), activation, review screenshots, submitting first IAPs with a version, license/sandbox testers. Use when the user sets up, prices, re-prices or tests in-app products/IAPs, or hits Play onetimeproducts / inappproducts 403 or App Store IAP submission errors.
--- ---
# In-app purchases on both stores # In-app purchases on both stores
@ -34,6 +34,25 @@ Apple), `reviewScreenshot`.
4. Test: Sandbox testers (Users and Access > Sandbox) on device; or a StoreKit configuration file in Xcode for local 4. Test: Sandbox testers (Users and Access > Sandbox) on device; or a StoreKit configuration file in Xcode for local
runs (those transactions are signed locally and fail server-side receipt checks). runs (those transactions are signed locally and fail server-side receipt checks).
## Many prices at once: the price sheet (both stores)
For price changes after the products exist (sales, regional pricing, a new price everywhere), keep one CSV:
`product,store,region,currency,price,start_date` (`store` = apple | play | both; `region` ISO alpha-2; `start_date`
App Store only, a future YYYY-MM-DD).
1. `storebound prices export` writes the current prices of both stores to `store/prices.csv` (`--with-automatic` adds
Apple's equalized prices as `kind=auto`, for reference; they are ignored on import).
2. Edit the rows. Apple only takes its own price points: an exact value per territory (the error lists the
neighbours); Play takes any price in the region's currency.
3. `storebound prices import --dry` (MCP: `prices_diff`) prints the diff table: product, store, region, from, current,
new, change %. Show it to the owner.
4. The owner runs `storebound prices import` (asks first; not an MCP tool). Apple: one new price schedule per changed
product (base territory, default the current one or `prices.baseTerritory`, plus every manual price; territories
without one follow Apple's equalization). Play: the purchase option's regional prices are replaced; regions the
product has no price for get `convertRegionPrices` from the USD base, `--reconvert` resets every region the CSV
does not list.
- Future-dated changes (`start_date`) are App Store only; Play has no scheduled prices (change them on the day).
- Apple's IAP pricing guide warns that a price **increase cannot be reverted** once it is in effect: tell the owner.
- Prices are public the moment the stores apply them. Never import without the owner reading the diff.
## Gotchas ## Gotchas
- Play legacy `inappproducts` returns 403 "Please migrate to the new publishing API" -> use - Play legacy `inappproducts` returns 403 "Please migrate to the new publishing API" -> use
`monetization.onetimeproducts`. `monetization.onetimeproducts`.

View file

@ -63,6 +63,9 @@ const config: ShipConfig = {
}, },
}, },
// Price changes later for many products / regions: storebound prices export, edit store/prices.csv, prices import --dry.
// prices: { csv: 'store/prices.csv', baseTerritory: 'USA' },
// Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a // Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a
// region by hand (Apple needs an existing price point with exactly that value). // region by hand (Apple needs an existing price point with exactly that value).
products: [ products: [

140
tests/prices.test.ts Normal file
View file

@ -0,0 +1,140 @@
import { describe, expect, test } from 'bun:test';
import {
alpha2, appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, playPatchBody, playPriceRows, playRegions, pointKey,
type PriceRow, pricesCsv, sameSchedule,
} from '../cli/lib/prices';
import { TOOLS, toArgv } from '../cli/mcp-tools';
const P = 'com.example.game.remove_ads';
const csv = `product,store,region,currency,price,start_date
${P},both,US,USD,2.99,
${P},apple,TR,TRY,99.99,
${P},apple,TR,TRY,129.99,2026-12-01
${P},play,TR,TRY,109.99,
`;
describe('price csv', () => {
test('parse: both expands, apple territory, scheduled change', () => {
const rows = parsePriceCsv(csv, { products: [P], today: '2026-10-10' });
expect(rows.map(r => `${r.store} ${r.region} ${r.territory ?? '-'} ${r.price} ${r.startDate ?? ''}`)).toEqual([
'apple US USA 2.99 ', 'play US - 2.99 ', 'apple TR TUR 99.99 ', 'apple TR TUR 129.99 2026-12-01', 'play TR - 109.99 ']);
});
test('parse errors are all reported', () => {
const bad = `product,store,region,currency,price,start_date
nope id,apple,US,USD,1,
${P},web,USA,usd,-1,
${P},play,TR,TRY,10,2026-12-01
${P},apple,TR,TRY,10,2026-01-01
${P},apple,US,USD,1,
${P},apple,US,USD,1,
x.y,apple,ZZ,USD,1,
`;
try { parsePriceCsv(bad, { products: [P], today: '2026-10-10' }); throw new Error('no'); } catch (e) {
const m = String(e);
for (const s of ['row 2: product "nope id"', 'store must be apple, play or both', 'must be ISO alpha-2', 'price must be a positive number', 'App Store only (Play has no scheduled prices)',
'is not in the future', 'listed twice', 'x.y is not in products[]', 'no App Store territory known for ZZ']) expect(m).toContain(s);
}
});
test('csv round trip', () => {
const rows = parsePriceCsv(csv, { today: '2026-10-10' });
const out = pricesCsv(rows);
expect(out.split('\n')[0]).toBe('product,store,region,currency,price,start_date,kind');
expect(out).toContain(`${P},apple,TR,TRY,129.99,2026-12-01,manual`);
expect(parsePriceCsv(out, { today: '2026-10-10' })).toHaveLength(rows.length);
expect(alpha2('TUR')).toBe('TR');
});
});
describe('store state -> rows', () => {
test('apple manual prices with included price points and territories', () => {
const rows = applePriceRows(P, {
data: [{ attributes: { startDate: null }, relationships: { inAppPurchasePricePoint: { data: { id: 'pp-us' } }, territory: { data: { id: 'USA' } } } },
{ attributes: { startDate: '2026-12-01' }, relationships: { inAppPurchasePricePoint: { data: { id: 'pp-tr' } }, territory: { data: { id: 'TUR' } } } }],
included: [{ type: 'inAppPurchasePricePoints', id: 'pp-us', attributes: { customerPrice: '2.99' } }, { type: 'inAppPurchasePricePoints', id: 'pp-tr', attributes: { customerPrice: '129.99' } },
{ type: 'territories', id: 'USA', attributes: { currency: 'USD' } }, { type: 'territories', id: 'TUR', attributes: { currency: 'TRY' } }],
});
expect(rows).toEqual([
{ product: P, store: 'apple', region: 'US', territory: 'USA', currency: 'USD', price: 2.99, pointId: 'pp-us' },
{ product: P, store: 'apple', region: 'TR', territory: 'TUR', currency: 'TRY', price: 129.99, pointId: 'pp-tr', startDate: '2026-12-01' }]);
});
test('play regional configs', () => {
expect(playPriceRows({ productId: P, purchaseOptions: [{ purchaseOptionId: 'buy', regionalPricingAndAvailabilityConfigs: [
{ regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 }, availability: 'AVAILABLE' },
{ regionCode: 'RU', price: { currencyCode: 'RUB', units: '100' }, availability: 'NO_LONGER_AVAILABLE' }] }] }))
.toEqual([{ product: P, store: 'play', region: 'US', currency: 'USD', price: 2.99 }]);
});
});
describe('diff', () => {
const current: PriceRow[] = [
{ product: P, store: 'apple', region: 'US', territory: 'USA', currency: 'USD', price: 2.99 },
{ product: P, store: 'apple', region: 'DE', territory: 'DEU', currency: 'EUR', price: 2.99, kind: 'auto' },
{ product: P, store: 'play', region: 'TR', currency: 'TRY', price: 99.99 },
];
test('same / change / new (auto prices count as new manual ones)', () => {
const wanted = parsePriceCsv(`product,store,region,currency,price\n${P},apple,US,USD,2.99\n${P},apple,DE,EUR,3.49\n${P},play,TR,TRY,109.99\n${P},play,JP,JPY,450\n`);
expect(diffPrices(current, wanted).map(d => `${d.store} ${d.region} ${d.from} -> ${d.to} ${d.change}`)).toEqual([
'apple US 2.99 -> 2.99 same', 'apple DE 2.99 -> 3.49 new', 'play TR 99.99 -> 109.99 change', 'play JP null -> 450 new']);
const t = diffTable(diffPrices(current, wanted));
expect(t).toContain('+10.0%');
expect(t).not.toContain(' US ');
expect(diffTable([])).toBe('no price changes');
});
});
describe('play regions', () => {
const conv = { US: { regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 } }, TR: { regionCode: 'TR', price: { currencyCode: 'TRY', units: '89', nanos: 990000000 } },
JP: { regionCode: 'JP', price: { currencyCode: 'JPY', units: '450' } } };
const cur = [{ regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 } }, { regionCode: 'TR', price: { currencyCode: 'TRY', units: '99', nanos: 990000000 } }];
test('csv over current, converted fills new regions, reconvert resets unlisted', () => {
const want = parsePriceCsv(`product,store,region,currency,price\n${P},play,TR,TRY,109.99\n`);
const r = playRegions(cur, want, conv);
expect(r.map(x => `${x.regionCode} ${x.price.units}.${(x.price.nanos ?? 0) / 1e7}`)).toEqual(['JP 450.0', 'TR 109.99', 'US 2.99']);
const r2 = playRegions([{ regionCode: 'JP', price: { currencyCode: 'JPY', units: '600' } }], [], conv, { reconvert: true });
expect(r2.find(x => x.regionCode === 'JP')!.price.units).toBe('450');
expect(() => playRegions(cur, parsePriceCsv(`product,store,region,currency,price\n${P},play,TR,USD,3\n`), conv)).toThrow('prices this region in TRY');
expect(() => playRegions(cur, parsePriceCsv(`product,store,region,currency,price\n${P},play,KP,KPW,3\n`), conv)).toThrow('does not sell in region KP');
});
test('patch body replaces only the purchase option prices and drops output-only state', () => {
const body = playPatchBody({ productId: P, listings: [{ languageCode: 'en-US' }], purchaseOptions: [{ purchaseOptionId: 'buy', state: 'ACTIVE', buyOption: { legacyCompatible: true }, regionalPricingAndAvailabilityConfigs: [] }] },
[{ regionCode: 'US', availability: 'AVAILABLE', price: { currencyCode: 'USD', units: '1' } }]);
expect(body.purchaseOptions![0]).toEqual({ purchaseOptionId: 'buy', buyOption: { legacyCompatible: true }, regionalPricingAndAvailabilityConfigs: [{ regionCode: 'US', availability: 'AVAILABLE', price: { currencyCode: 'USD', units: '1' } }] });
expect(body.listings).toEqual([{ languageCode: 'en-US' }]);
});
});
describe('apple schedule', () => {
const cur: PriceRow[] = [
{ product: P, store: 'apple', region: 'US', territory: 'USA', currency: 'USD', price: 2.99, pointId: 'pp-us' },
{ product: P, store: 'apple', region: 'TR', territory: 'TUR', currency: 'TRY', price: 99.99, pointId: 'pp-tr' },
{ product: P, store: 'apple', region: 'JP', territory: 'JPN', currency: 'JPY', price: 400, pointId: 'pp-jp' },
];
test('csv territories replaced, dated rows keep the current price, others kept, base first', () => {
const want = parsePriceCsv(`product,store,region,currency,price,start_date\n${P},apple,TR,TRY,129.99,2026-12-01\n${P},apple,JP,JPY,500,\n`, { today: '2026-10-10' });
const s = appleSchedule(cur, want, 'USA');
expect(s).toEqual([
{ territory: 'USA', price: 2.99, startDate: null },
{ territory: 'JPN', price: 500, startDate: null },
{ territory: 'TUR', price: 99.99, startDate: null },
{ territory: 'TUR', price: 129.99, startDate: '2026-12-01' }]);
expect(sameSchedule(s, s.slice().reverse())).toBe(true);
expect(sameSchedule(s, s.slice(1))).toBe(false);
expect(() => appleSchedule([], want, 'USA')).toThrow('base territory USA needs a current price');
});
test('schedule body with local ids and price point ids', () => {
const entries = [{ territory: 'USA', price: 2.99, startDate: null }, { territory: 'TUR', price: 129.99, startDate: '2026-12-01' }];
const points = new Map([[pointKey('USA', 2.99), 'pp-us'], [pointKey('TUR', '129.99'), 'pp-tr2']]);
const body = appleScheduleBody('iap1', 'USA', entries, points) as { data: { relationships: Record<string, { data: unknown }> }; included: unknown[] };
expect(body.data.relationships.baseTerritory!.data).toEqual({ type: 'territories', id: 'USA' });
expect(body.data.relationships.manualPrices!.data).toEqual([{ type: 'inAppPurchasePrices', id: '${p0}' }, { type: 'inAppPurchasePrices', id: '${p1}' }]);
expect(body.included[1]).toEqual({ type: 'inAppPurchasePrices', id: '${p1}', attributes: { startDate: '2026-12-01' },
relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id: 'iap1' } }, inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: 'pp-tr2' } } } });
expect(() => appleScheduleBody('iap1', 'USA', [{ territory: 'GBR', price: 1, startDate: null }], points)).toThrow('no price point id for GBR 1');
});
test('mcp: diff is read-only with --dry, export is a dry-by-default local write', () => {
const t = (n: string) => TOOLS.find(x => x.name === n)!;
expect(t('prices_diff').write).toBe(false);
expect(toArgv(t('prices_diff'), { store: 'apple' }).argv).toEqual(['prices', 'import', '--store', 'apple', '--dry']);
expect(toArgv(t('prices_export'), {}).argv).toEqual(['prices', 'export', '--dry']);
});
});