// In-app products on Google Play through monetization.onetimeproducts (the legacy inappproducts API answers 403 // "Please migrate to the new publishing API" for apps on the new model). // storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes] // Per product: convertRegionPrices (USD base -> every region at local price points, plus regionsVersion), your // explicit overrides on top, PATCH ...?allowMissing=true (create or update), then activate the purchase option // (new options start as DRAFT) with purchaseOptions:batchUpdateStates. Idempotent. // New products (with their first prices) and listing text changes are written directly. Activating the purchase // option of an EXISTING product needs a confirmation (a terminal question or --yes). Changing what an existing product // costs or where it is sold (regional prices, the price for other regions, dropped regions) is the owner's call: // --yes never confirms it (flows add --yes); it needs --allow-price-changes, the owner's answer in a terminal (no // --yes) or the desktop owner action. Without that the run writes everything else, prints one "price change on // ( -> ) needs the owner" line per product and exits 3 (declined), so a flow stops there. The dry run marks // those rows "owner". (lib/price-gate.ts) // Play has no non-renewing type: a `non_renewing` product is a plain one-time product here (your app consumes it // when the access ends). Products with `platforms` that leave out android are skipped. // --dry ends with the standard plan table (lib/dry.ts). After a real run the product list is read again: a product // created a moment ago can be missing from it for a minute, and that gap is named (docs/pitfalls.md). import { playCodes, sellsOn } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { dryPlan } from '../lib/dry'; import { done, log, step, warn } from '../lib/log'; import { allowPriceChanges, type HeldPrice, holdPriceChanges } from '../lib/price-gate'; import { LAG_HINT, reportMissing } from '../lib/store-lag'; import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money'; import { playClient } from './client'; const OPTION = 'buy'; interface Converted { convertedRegionPrices?: Record; convertedOtherRegionsPrice?: { usdPrice: Money; eurPrice: Money }; regionVersion?: { version: string }; } type PlayMoney = Omit & { units?: string }; export interface Existing { productId: string; listings?: { languageCode: string; title?: string; description?: string }[]; purchaseOptions?: { purchaseOptionId: string; state?: string; regionalPricingAndAvailabilityConfigs?: { regionCode: string; price?: PlayMoney; availability?: string }[]; newRegionsConfig?: { usdPrice?: PlayMoney; eurPrice?: PlayMoney; availability?: string } }[]; } const money = (m?: PlayMoney): string => (m ? `${m.currencyCode} ${fromMoney(m).toFixed(2)}` : '-'); export interface ProductChange { text: string; /** price or region change of an existing product: the owner's call (--allow-price-changes), never --yes */ owner: boolean; /** owner rows: per region, current -> wanted */ prices?: HeldPrice['changes']; } /** * What a run would change on one product, from the product Play has now (oneTimeProducts list) and the body the run * would PATCH. The PATCH replaces listings and purchase options, so a language on Play that is not in the config is * removed. Prices are compared per region (currency and amount). Empty: nothing would change. Pure. */ export function productChanges(cur: Existing | undefined, want: { listings: { languageCode: string; title: string; description: string }[]; regions: RegionConfig[]; newRegions: { usdPrice: Money; eurPrice: Money } }): ProductChange[] { if (!cur) return [{ text: 'create', owner: false }]; const out: ProductChange[] = []; const text = (t: string) => out.push({ text: t, owner: false }); const owner = (t: string, prices: HeldPrice['changes']) => out.push({ text: t, owner: true, prices }); const have = new Map((cur.listings ?? []).map(l => [l.languageCode, l])); const added = want.listings.filter(l => !have.has(l.languageCode)).map(l => l.languageCode); const changed = want.listings.filter(l => { const h = have.get(l.languageCode); return h && ((h.title ?? '') !== l.title || (h.description ?? '') !== l.description); }).map(l => l.languageCode); const removed = [...have.keys()].filter(c => !want.listings.some(l => l.languageCode === c)); if (added.length) text(`listings added: ${added.join(', ')}`); if (changed.length) text(`listings changed: ${changed.join(', ')}`); if (removed.length) text(`listings removed: ${removed.join(', ')}`); const opt = cur.purchaseOptions?.find(o => o.purchaseOptionId === OPTION); const now = new Map((opt?.regionalPricingAndAvailabilityConfigs ?? []).map(r => [r.regionCode, r])); const prices = want.regions.flatMap(r => { const h = now.get(r.regionCode)?.price; return h && h.currencyCode === r.price.currencyCode && Math.round(fromMoney(h) * 100) === Math.round(fromMoney(r.price) * 100) ? [] : [{ region: r.regionCode, from: money(h), to: money(r.price) }]; }); const gone = [...now.keys()].filter(c => !want.regions.some(r => r.regionCode === c)); if (prices.length) owner(`prices in ${prices.length} region(s): ${prices.slice(0, 6).map(x => `${x.region} ${x.from} -> ${x.to}`).join(', ')}${prices.length > 6 ? ', ...' : ''}`, prices); if (gone.length) owner(`regions dropped: ${gone.join(', ')}`, gone.map(c => ({ region: c, from: money(now.get(c)?.price), to: 'not sold' }))); const nr = opt?.newRegionsConfig; if (money(nr?.usdPrice) !== money(want.newRegions.usdPrice) || money(nr?.eurPrice) !== money(want.newRegions.eurPrice)) { const from = `${money(nr?.usdPrice)} / ${money(nr?.eurPrice)}`, to = `${money(want.newRegions.usdPrice)} / ${money(want.newRegions.eurPrice)}`; owner(`other regions ${from} -> ${to}`, [{ region: 'other regions', from, to }]); } if (opt?.state !== 'ACTIVE') text(`purchase option ${opt?.state ?? 'missing'} -> ACTIVE`); return out; } export async function listOneTimeProducts(play: Awaited>): Promise { const out: Existing[] = []; let page = ''; do { // camelCase "oneTimeProducts" here; the lowercase path answers with an HTML 404 const r = await play.call<{ oneTimeProducts?: Existing[]; nextPageToken?: string }>('GET', `${play.API}/oneTimeProducts?pageSize=100${page ? '&pageToken=' + page : ''}`); out.push(...(r.oneTimeProducts ?? [])); page = r.nextPageToken ?? ''; } while (page); return out; } export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const pkg = need(config.play?.packageName, 'play.packageName'); const only = ctx.args.list('only'); const products = need(config.products, 'products').filter(p => (!only.length || only.includes(p.id)) && sellsOn(config, p, 'play')); const dry = ctx.args.has('dry'); const play = await playClient(root, pkg); const existing = new Map((await listOneTimeProducts(play)).map(p => [p.productId, p])); const langs = new Map(config.languages.map(l => [l.id, l])); const plan: { p: (typeof products)[number]; tag: string; body: Record; version: string; cur?: Existing; changes: ProductChange[]; regions: number; listings: number }[] = []; for (const [i, p] of products.entries()) { const tag = step(i + 1, products.length, p.id); const conv = await play.call('POST', `${play.API}/pricing:convertRegionPrices`, { price: toMoney('USD', p.usd) }); if (!conv.convertedRegionPrices || !conv.convertedOtherRegionsPrice) throw new Error(`${p.id}: convertRegionPrices returned no prices`); const regions = playRegionConfigs(conv.convertedRegionPrices, p.overrides); const listings = Object.entries(p.listings).flatMap(([id, l]) => playCodes(langs.get(id)!).map(languageCode => ({ languageCode, title: l.title, description: l.description }))); if (!listings.length) throw new Error(`${p.id}: no listing language has a Play code`); const body = { packageName: pkg, productId: p.id, listings, purchaseOptions: [{ purchaseOptionId: OPTION, buyOption: { legacyCompatible: true, multiQuantityEnabled: false }, regionalPricingAndAvailabilityConfigs: regions, newRegionsConfig: { usdPrice: conv.convertedOtherRegionsPrice.usdPrice, eurPrice: conv.convertedOtherRegionsPrice.eurPrice, availability: 'AVAILABLE' }, }], }; const cur = existing.get(p.id); const changes = productChanges(cur, { listings, regions, newRegions: conv.convertedOtherRegionsPrice }); const what = !changes.length ? 'unchanged' : !cur ? `${dry ? 'would create' : 'create'} · ${regions.length} regions · ${listings.length} listings` : `${dry ? 'would update' : 'update'} ${changes.map(c => c.text).join('; ')}`; log('play', `${tag}: ${what}${dry ? ' (dry)' : ''}`); plan.push({ p, tag, body, version: conv.regionVersion?.version ?? '2022/02', cur, changes, regions: regions.length, listings: listings.length }); } if (dry) { console.log(dryPlan('play products', plan.map(x => !x.changes.length ? { item: x.p.id, action: 'keep' } : !x.cur ? { item: x.p.id, action: 'create', detail: `${x.regions} regions, ${x.listings} listings, USD ${x.p.usd}` } : { item: x.p.id, action: x.changes.some(c => c.owner) ? 'update (owner: price change, --allow-price-changes)' : 'update', detail: x.changes.map(c => c.text).join('; ') }))); const absent = plan.filter(x => !x.cur).map(x => x.p.id); if (absent.length) log('play', `not on Google Play: ${absent.join(', ')}. If you created them in the last minutes, ${LAG_HINT} before acting on this plan`); done('play', 'products checked (dry run, nothing written)'); return; } // prices and regions of existing products: the owner's call (never --yes); activation of an existing one: confirmed const priced = plan.filter(x => x.cur && x.changes.some(c => c.owner)); const priceOk = await allowPriceChanges(ctx, priced.length, `Change prices / regions of ${priced.length} existing product(s) on Google Play (${priced.map(x => x.p.id).join(', ')})? Buyers see new prices as soon as Play applies them.`); const optionOf = (x: (typeof plan)[number]) => x.cur?.purchaseOptions?.find(o => o.purchaseOptionId === OPTION); // an existing product without the option gets it with the prices, so it waits for them const activate = plan.filter(x => x.cur && optionOf(x)?.state !== 'ACTIVE' && (optionOf(x) || priceOk)); const activateOk = !activate.length || await confirm(ctx, `Activate the purchase option of ${activate.length} existing product(s) on Google Play (${activate.map(x => x.p.id).join(', ')})? Buyers can buy them as soon as the app is published.`); if (!activateOk) warn('play', `activation of ${activate.length} existing product(s) skipped: confirm with --yes or in a terminal`); const held: HeldPrice[] = []; for (const x of plan) { if (!x.changes.length) continue; const id = encodeURIComponent(x.p.id); const v = encodeURIComponent(x.version); const ownerChange = !!x.cur && x.changes.some(c => c.owner); if (x.cur && ownerChange && !priceOk) { held.push({ id: x.p.id, changes: x.changes.flatMap(c => c.prices ?? []) }); if (x.changes.some(c => c.text.startsWith('listings '))) { await play.call('PATCH', `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`, x.body); log('play', `${x.tag}: listings updated (prices unchanged)`); } } else if (!x.cur || x.changes.some(c => c.owner || c.text.startsWith('listings '))) { const mask = x.cur && !ownerChange ? 'listings' : 'listings,purchaseOptions'; await play.call('PATCH', `${play.API}/onetimeproducts/${id}?allowMissing=true®ionsVersion.version=${v}&updateMask=${mask}`, x.body); log('play', `${x.tag}: ${x.cur ? 'updated' : 'created'} · ${x.regions} regions · ${x.listings} listings`); } // a new product's option starts as DRAFT; an existing one is activated after the confirmation if (optionOf(x)?.state === 'ACTIVE' || (x.cur && (!activateOk || !activate.includes(x)))) continue; try { await play.call('POST', `${play.API}/oneTimeProducts/${id}/purchaseOptions:batchUpdateStates`, { requests: [{ activatePurchaseOptionRequest: { packageName: pkg, productId: x.p.id, purchaseOptionId: OPTION } }], }); log('play', `${x.tag}: purchase option activated`); } catch (e) { warn('play', `${x.tag}: activate failed: ${(e as Error).message}`); } } const created = plan.filter(x => !x.cur && x.changes.length).map(x => x.p.id); if (created.length) reportMissing('play', 'new products', created, (await listOneTimeProducts(play)).map(p => p.productId), { fail: false }); holdPriceChanges(held); done('play', held.length ? 'products done, price changes left for the owner' : 'products done'); }