// 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] // 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. // 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. import { playCodes, sellsOn } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, step, warn } from '../lib/log'; import { type Money, playRegionConfigs, toMoney } from '../lib/money'; import { playClient } from './client'; const OPTION = 'buy'; interface Converted { convertedRegionPrices?: Record; convertedOtherRegionsPrice?: { usdPrice: Money; eurPrice: Money }; regionVersion?: { version: string }; } interface Existing { productId: string; purchaseOptions?: { purchaseOptionId: string; state?: string }[] } 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])); 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); if (dry) { log('play', `${tag}: ${cur ? 'update' : 'create'} · ${regions.length} regions · ${listings.length} listings (dry)`); continue; } const v = encodeURIComponent(conv.regionVersion?.version ?? '2022/02'); await play.call('PATCH', `${play.API}/onetimeproducts/${encodeURIComponent(p.id)}?allowMissing=true®ionsVersion.version=${v}&updateMask=listings,purchaseOptions`, body); log('play', `${tag}: ${cur ? 'updated' : 'created'} · ${regions.length} regions · ${listings.length} listings`); const active = cur?.purchaseOptions?.find(o => o.purchaseOptionId === OPTION)?.state === 'ACTIVE'; if (active) continue; try { await play.call('POST', `${play.API}/oneTimeProducts/${encodeURIComponent(p.id)}/purchaseOptions:batchUpdateStates`, { requests: [{ activatePurchaseOptionRequest: { packageName: pkg, productId: p.id, purchaseOptionId: OPTION } }], }); log('play', `${tag}: purchase option activated`); } catch (e) { warn('play', `${tag}: activate failed: ${(e as Error).message}`); } } done('play', dry ? 'products checked (dry run, nothing written)' : 'products done'); }