133 lines
9.6 KiB
TypeScript
133 lines
9.6 KiB
TypeScript
// Auto-renewable subscriptions on Google Play through monetization.subscriptions, from `subscriptions` in the
|
|
// config: one Play subscription per entry with listings per Play language (title, description, benefits), one
|
|
// auto-renewing base plan (billing period, grace period, regional prices from convertRegionPrices + overrides + optional
|
|
// CSV rows), activated, and the introductory offer (free trial, pay as you go, pay up front) for people who never had
|
|
// the subscription, activated, plus `offers` (no acquisition targeting, offer tag = id: your app decides who sees
|
|
// them). Play has no subscription groups (an Apple concept) and no API for promo codes (Play Console > Monetize >
|
|
// Promo codes). Idempotent: compares first.
|
|
// storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes]
|
|
// Prints the plan first. In a terminal it asks before writing. Without a terminal (MCP, CI) and without --yes it
|
|
// creates what is new only and leaves price changes of existing base plans to a terminal run. A base plan price
|
|
// change applies to new subscribers; existing ones keep their price until you run a price migration in Play Console.
|
|
// Pure parts and sources: lib/subscriptions.ts.
|
|
import { sellsOn } from '../lib/config';
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { confirm, need } from '../lib/ctx';
|
|
import { done, log, step, warn } from '../lib/log';
|
|
import { type Money, toMoney } from '../lib/money';
|
|
import {
|
|
allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions,
|
|
playSubscriptionBody,
|
|
} from '../lib/subscriptions';
|
|
import { subscriptionCsv } from '../apple/subscriptions';
|
|
import { createOnce } from '../lib/upsert';
|
|
import { playClient, type PlayClient } from './client';
|
|
|
|
/** GET of one Play resource, null on 404. */
|
|
export async function getOrNull<T>(play: PlayClient, url: string): Promise<T | null> {
|
|
try { return await play.call<T>('GET', url); } catch (e) { if (/-> 404/.test(String(e))) return null; throw e; }
|
|
}
|
|
|
|
const TAG = 'play';
|
|
interface Converted { convertedRegionPrices?: Record<string, { regionCode: string; price: Money }>; convertedOtherRegionsPrice?: { usdPrice: Money; eurPrice: Money }; regionVersion?: { version: string } }
|
|
|
|
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 subs = allSubscriptions(need(config.subscriptions, 'subscriptions')).map(x => x.sub).filter(s => (!only.length || only.includes(s.id)) && sellsOn(config, s, 'play'));
|
|
const dry = ctx.args.has('dry');
|
|
const csv = subscriptionCsv(ctx, 'play');
|
|
const play = await playClient(root, pkg);
|
|
const existing = new Map<string, PlaySubscription>();
|
|
let page = '';
|
|
do {
|
|
const r = await play.call<{ subscriptions?: PlaySubscription[]; nextPageToken?: string }>('GET', `${play.API}/subscriptions?pageSize=100${page ? '&pageToken=' + page : ''}`);
|
|
for (const s of r.subscriptions ?? []) existing.set(s.productId, s);
|
|
page = r.nextPageToken ?? '';
|
|
} while (page);
|
|
|
|
const convert = async (usd: number): Promise<Required<Converted>> => {
|
|
const conv = await play.call<Converted>('POST', `${play.API}/pricing:convertRegionPrices`, { price: toMoney('USD', usd) });
|
|
if (!conv.convertedRegionPrices || !conv.convertedOtherRegionsPrice) throw new Error(`convertRegionPrices returned no prices for USD ${usd}`);
|
|
return { convertedRegionPrices: conv.convertedRegionPrices, convertedOtherRegionsPrice: conv.convertedOtherRegionsPrice, regionVersion: conv.regionVersion ?? { version: '2022/02' } };
|
|
};
|
|
|
|
const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType<typeof playSubDiff>; version: string; offerMissing: boolean; extraMissing: string[] }[] = [];
|
|
for (const [i, s] of subs.entries()) {
|
|
const conv = await convert(s.usd);
|
|
const regions = playSubRegions(s, conv.convertedRegionPrices, csv);
|
|
const body = playSubscriptionBody(pkg, s, config.languages, regions, conv.convertedOtherRegionsPrice);
|
|
const cur = existing.get(s.id) ?? null;
|
|
const diff = playSubDiff(cur, body);
|
|
let offerMissing = !!s.intro;
|
|
let extraMissing = (s.offers ?? []).map(o => o.id);
|
|
if ((s.intro || s.offers?.length) && cur?.basePlans?.some(b => b.basePlanId === basePlanId(s))) {
|
|
const offers = await play.call<{ subscriptionOffers?: { offerId: string }[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.id)}/basePlans/${basePlanId(s)}/offers`);
|
|
const have = new Set((offers.subscriptionOffers ?? []).map(o => o.offerId));
|
|
offerMissing = !!s.intro && !have.has(offerId(s));
|
|
extraMissing = extraMissing.filter(x => !have.has(x));
|
|
}
|
|
const what = diff.create ? `create, base plan ${basePlanId(s)} (${s.period}) in ${regions.length} regions`
|
|
: [diff.listings.length ? `listings ${diff.listings.join(', ')}` : '', diff.basePlan === 'new' ? `new base plan ${basePlanId(s)}` : diff.basePlan === 'changed' ? `base plan ${basePlanId(s)}: ${diff.priceChanges.length} regional price change(s)` : '',
|
|
diff.activate ? 'activate base plan' : ''].filter(Boolean).join(' · ') || 'up to date';
|
|
log(TAG, `${step(i + 1, subs.length, s.id)}: ${what}${offerMissing ? ` · intro offer ${offerId(s)} (${s.intro!.type})` : ''}${extraMissing.length ? ` · offers ${extraMissing.join(', ')}` : ''}`);
|
|
for (const p of diff.priceChanges.slice(0, 5)) log(TAG, ` ${p.regionCode}: ${p.from} -> ${p.to}`);
|
|
if (diff.priceChanges.length > 5) log(TAG, ` ... ${diff.priceChanges.length - 5} more`);
|
|
work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing });
|
|
}
|
|
if (dry) { done(TAG, 'subscriptions planned (dry run, nothing written)'); return; }
|
|
const todo = work.filter(w => w.diff.create || w.diff.listings.length || w.diff.basePlan !== 'same' || w.diff.activate || w.offerMissing || w.extraMissing.length);
|
|
if (!todo.length) { done(TAG, 'subscriptions up to date'); return; }
|
|
|
|
const yes = ctx.args.has('yes');
|
|
let liveOk = yes;
|
|
if (process.stdin.isTTY && !yes) {
|
|
if (!(await confirm(ctx, `Write ${todo.length} subscription(s) to Google Play? Activated base plans and offers can be bought as soon as the app is published; price changes apply to new subscribers.`))) return;
|
|
liveOk = true;
|
|
} else if (!yes) log(TAG, 'not a terminal: creating what is new only; price changes of existing base plans wait for a terminal run');
|
|
|
|
for (const { s, body, diff, version, offerMissing, extraMissing } of todo) {
|
|
const v = encodeURIComponent(version);
|
|
const id = encodeURIComponent(s.id);
|
|
const cur = existing.get(s.id);
|
|
if (diff.create) {
|
|
// product ids are unique: a 409 (or an unclear answer, then a fresh GET) finds the existing one, never a second
|
|
const r = await createOnce({ what: `subscription ${s.id}`, tag: TAG, unique: true,
|
|
find: () => getOrNull<PlaySubscription>(play, `${play.API}/subscriptions/${id}`),
|
|
create: () => play.call<PlaySubscription>('POST', `${play.API}/subscriptions?productId=${id}®ionsVersion.version=${v}`, body) });
|
|
log(TAG, `${s.id}: ${r.created ? 'created' : 'exists already (made by an earlier run); run again to compare it with the config'}`);
|
|
if (!r.created) continue;
|
|
} else if (diff.listings.length || diff.basePlan !== 'same') {
|
|
if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) warn(TAG, `${s.id}: ${diff.priceChanges.length} price change(s) skipped (run in a terminal to confirm)`);
|
|
else {
|
|
await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans®ionsVersion.version=${v}`, playPatchSubscription(cur!, body));
|
|
log(TAG, `${s.id}: updated`);
|
|
}
|
|
}
|
|
if (diff.activate) {
|
|
await play.call('POST', `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}:activate`, { packageName: pkg, productId: s.id, basePlanId: basePlanId(s) });
|
|
log(TAG, `${s.id}: base plan ${basePlanId(s)} activated`);
|
|
}
|
|
const regions = body.basePlans![0]!.regionalConfigs!.map(r => ({ regionCode: r.regionCode, price: r.price! }));
|
|
const createOffer = async (o: { type: string; usd?: number }, extra?: NonNullable<typeof s.offers>[number]) => {
|
|
let offerRegions;
|
|
if (o.type === 'pay-up-front') {
|
|
const oc = await convert(o.usd!);
|
|
offerRegions = { regions: Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), other: oc.convertedOtherRegionsPrice };
|
|
}
|
|
const ob = playOfferBody(pkg, s, regions, offerRegions, extra);
|
|
const offerUrl = `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}/offers`;
|
|
// offer ids are unique per base plan: a 409 or an unclear answer finds the existing offer, never a second one
|
|
const r = await createOnce({ what: `${s.id} offer ${ob.offerId}`, tag: TAG, unique: true,
|
|
find: () => getOrNull<{ state?: string }>(play, `${offerUrl}/${encodeURIComponent(ob.offerId)}`),
|
|
create: () => play.call<{ state?: string }>('POST', `${offerUrl}?offerId=${encodeURIComponent(ob.offerId)}®ionsVersion.version=${v}`, ob) });
|
|
if (r.value.state !== 'ACTIVE')
|
|
await play.call('POST', `${offerUrl}/${encodeURIComponent(ob.offerId)}:activate`, { packageName: pkg, productId: s.id, basePlanId: basePlanId(s), offerId: ob.offerId });
|
|
log(TAG, `${s.id}: offer ${ob.offerId} ${r.created ? 'created and activated' : 'exists already, active'}`);
|
|
};
|
|
if (s.intro && offerMissing) await createOffer(s.intro);
|
|
for (const o of (s.offers ?? []).filter(x => extraMissing.includes(x.id))) await createOffer(o, o);
|
|
}
|
|
done(TAG, 'subscriptions done');
|
|
}
|