storebound/cli/lib/prices.ts
CRT 104cd79117 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.
2026-10-10 23:28:10 +08:00

227 lines
15 KiB
TypeScript

// 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))}`;