// 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(); 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) => `${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 | 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(); 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(); 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(); 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): Record { 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))}`;