164 lines
11 KiB
TypeScript
164 lines
11 KiB
TypeScript
// 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.
|
|
// New products and listing text changes are written directly. Changes to what an EXISTING product costs or where it
|
|
// is sold (regional prices, the price for other regions, dropped regions, activating its purchase option) are shown
|
|
// as a diff first and need a confirmation: a terminal question (or --yes), or under the desktop app an owner action
|
|
// confirm token. Without one (MCP, CI, serve) those changes are skipped with a warning; listing changes still go.
|
|
// 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 { 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<string, { regionCode: string; price: Money }>;
|
|
convertedOtherRegionsPrice?: { usdPrice: Money; eurPrice: Money };
|
|
regionVersion?: { version: string };
|
|
}
|
|
type PlayMoney = Omit<Money, 'units'> & { 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 / region / activation change of an existing product: asks first */ owner: boolean }
|
|
|
|
/**
|
|
* 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) => out.push({ text: t, owner: true });
|
|
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) ? [] : [`${r.regionCode} ${money(h)} -> ${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).join(', ')}${prices.length > 6 ? ', ...' : ''}`);
|
|
if (gone.length) owner(`regions dropped: ${gone.join(', ')}`);
|
|
const nr = opt?.newRegionsConfig;
|
|
if (money(nr?.usdPrice) !== money(want.newRegions.usdPrice) || money(nr?.eurPrice) !== money(want.newRegions.eurPrice))
|
|
owner(`other regions ${money(nr?.usdPrice)} / ${money(nr?.eurPrice)} -> ${money(want.newRegions.usdPrice)} / ${money(want.newRegions.eurPrice)}`);
|
|
if (opt?.state !== 'ACTIVE') owner(`purchase option ${opt?.state ?? 'missing'} -> ACTIVE`);
|
|
return out;
|
|
}
|
|
|
|
export async function listOneTimeProducts(play: Awaited<ReturnType<typeof playClient>>): Promise<Existing[]> {
|
|
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<void> {
|
|
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<string, unknown>; 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<Converted>('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 confirms)' : '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, regions and activation of existing products: the owner confirms the diff above first
|
|
const gated = plan.filter(x => x.cur && x.changes.some(c => c.owner));
|
|
const ok = !gated.length || await confirm(ctx, `Change prices / regions / activation of ${gated.length} existing product(s) on Google Play (${gated.map(x => x.p.id).join(', ')})? Buyers see new prices as soon as Play applies them.`);
|
|
if (!ok) warn('play', `price / region / activation changes of ${gated.length} existing product(s) skipped: confirm in a terminal (or the desktop owner action)`);
|
|
|
|
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);
|
|
const listingChange = x.changes.some(c => !c.owner);
|
|
if (x.cur && ownerChange && !ok) {
|
|
if (!listingChange) continue;
|
|
await play.call('PATCH', `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`, x.body);
|
|
log('play', `${x.tag}: listings updated (prices unchanged)`);
|
|
continue;
|
|
}
|
|
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`);
|
|
if (x.cur?.purchaseOptions?.find(o => o.purchaseOptionId === OPTION)?.state === 'ACTIVE') 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 });
|
|
done('play', 'products done');
|
|
}
|