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:
parent
c99febc45b
commit
104cd79117
8 changed files with 550 additions and 1 deletions
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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
227
cli/lib/prices.ts
Normal 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))}`;
|
||||||
|
|
@ -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
143
cli/prices.ts
Normal 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)');
|
||||||
|
}
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
140
tests/prices.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue