// 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] [--allow-price-changes] // Prints the plan first. In a terminal (no --yes) it asks before writing, price changes included. With --yes or // without a terminal it writes everything new (subscriptions with their first prices, listings, base plans, offers) // but never changes the prices of an existing base plan: that is the owner's call and needs --allow-price-changes // (flows never add it). The run then prints "price change on ( -> ) needs the owner" and exits 3 // (declined). 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 { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges } from '../lib/price-gate'; import { type Money, toMoney } from '../lib/money'; import { applyLimits, clampText, learnLimit, loadLimits, parseLimitError, saveLimits } from '../lib/play-limits'; import { allSubscriptions, basePlanId, offerId, type PlayOfferLike, playOfferBody, playOfferDiff, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, playSubscriptionBody, playSubTextChanges, } from '../lib/subscriptions'; import { subscriptionCsv } from '../apple/subscriptions'; import { createOnce } from '../lib/upsert'; import { playClient, type PlayClient } from './client'; import { stdinIsTTY } from '../lib/tty'; /** GET of one Play resource, null on 404. */ export async function getOrNull(play: PlayClient, url: string): Promise { try { return await play.call('GET', url); } catch (e) { if (/-> 404/.test(String(e))) return null; throw e; } } const TAG = 'play'; interface Converted { convertedRegionPrices?: Record; convertedOtherRegionsPrice?: { usdPrice: Money; eurPrice: Money }; regionVersion?: { version: string } } 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 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(); 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> => { const conv = await play.call('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 limits = loadLimits(root); const skipOut = ctx.args.has('skip-out-of-range-regions'); const prune = ctx.args.has('prune-languages'); // the offer the config would create (pay-up-front prices come from convertRegionPrices of the offer price) const buildOffer = async (s: typeof subs[number], regions: { regionCode: string; price: Money }[], o: { type: string; usd?: number }, extra?: NonNullable[number]) => { let offerRegions; if (o.type === 'pay-up-front') { const oc = await convert(o.usd!); offerRegions = { regions: applyLimits(Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), limits, skipOut).regions, other: oc.convertedOtherRegionsPrice }; } return playOfferBody(pkg, s, regions, offerRegions, extra); }; interface OfferDrift { offerId: string; phase: string[]; prices: HeldPrice['changes']; body: ReturnType } const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType; version: string; offerMissing: boolean; extraMissing: string[]; offerDrift: OfferDrift[] }[] = []; for (const [i, s] of subs.entries()) { const conv = await convert(s.usd); const lim = applyLimits(playSubRegions(s, conv.convertedRegionPrices, csv), limits, skipOut); const regions = lim.regions; for (const c of lim.clamps) warn(TAG, `${s.id}: price limit ${clampText(c)}${skipOut ? '' : ' (clamped; --skip-out-of-range-regions leaves such regions out)'}`); 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); const offerDrift: OfferDrift[] = []; if ((s.intro || s.offers?.length) && cur?.basePlans?.some(b => b.basePlanId === basePlanId(s))) { const offers = await play.call<{ subscriptionOffers?: PlayOfferLike[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.id)}/basePlans/${basePlanId(s)}/offers`); const haveOffers = new Map((offers.subscriptionOffers ?? []).map(o => [o.offerId ?? '', o] as const)); offerMissing = !!s.intro && !haveOffers.has(offerId(s)); extraMissing = extraMissing.filter(x => !haveOffers.has(x)); // offers that exist: phases (free / paid, duration, periods) and prices per region vs what the config would create const regionPrices = body.basePlans![0]!.regionalConfigs!.map(r => ({ regionCode: r.regionCode, price: r.price! })); const check: [string, { type: string; usd?: number }, NonNullable[number] | undefined][] = []; if (s.intro && haveOffers.has(offerId(s))) check.push([offerId(s), s.intro, undefined]); for (const o of s.offers ?? []) if (haveOffers.has(o.id)) check.push([o.id, o, o]); for (const [oid, o, extra] of check) { const want = await buildOffer(s, regionPrices, o, extra); const d = playOfferDiff(haveOffers.get(oid)!, want as unknown as PlayOfferLike); if (d.phase.length || d.prices.length) offerDrift.push({ offerId: oid, phase: d.phase, prices: d.prices, body: want }); } } const text = playSubTextChanges(diff, prune); const what = diff.create ? `create, base plan ${basePlanId(s)} (${s.period}) in ${regions.length} regions` : [...text.filter(x => !x.startsWith('grace')), diff.basePlan === 'new' ? `new base plan ${basePlanId(s)}` : diff.basePlan === 'changed' && diff.priceChanges.length ? `base plan ${basePlanId(s)}: ${diff.priceChanges.length} regional price change(s)` : '', ...text.filter(x => x.startsWith('grace')), 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(', ')}` : ''}`); if (diff.priceChanges.length) log(TAG, ' owner: price change of an existing base plan (--allow-price-changes)'); 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`); for (const d of offerDrift) { log(TAG, ` offer ${d.offerId} differs${d.phase.length ? `: ${d.phase.join('; ')}` : ''}${d.prices.length ? ` · ${d.prices.length} price(s), owner (--allow-price-changes)` : ''}`); for (const p of d.prices.slice(0, 5)) log(TAG, ` ${p.region}: ${p.from} -> ${p.to}`); } work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing, offerDrift }); } // money held behind --allow-price-changes: base plan prices (incl. other regions and regions dropped) and offer prices const moneyOf = (w: typeof work[number]): HeldPrice[] => [ ...(w.diff.basePlan === 'changed' && w.diff.priceChanges.length ? [{ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) }] : []), ...w.offerDrift.filter(d => d.prices.length).map(d => ({ id: `${w.s.id} offer ${d.offerId}`, changes: d.prices })), ]; if (dry) { const h = heldSection(work.flatMap(moneyOf), ctx.args.has('allow-price-changes')); if (h) console.log(h); 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 || w.offerDrift.length || (prune && w.diff.listingsRemoved?.length)); if (!todo.length) { done(TAG, 'subscriptions up to date'); return; } const yes = ctx.args.has('yes'); const priced = todo.filter(w => moneyOf(w).length); // the terminal question covers creates and other updates; price changes of existing base plans need // --allow-price-changes and their own question listing each old -> new (lib/price-gate.ts) if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') { 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 of existing base plans are not part of this answer (--allow-price-changes).`))) return; } const liveOk = await allowPriceChanges(ctx, priced.flatMap(moneyOf), `Change the prices (base plan, other regions, regions sold, offers) of ${priced.length} existing subscription(s) on Google Play? New subscribers pay the new price.`); if (!liveOk && priced.length) log(TAG, 'writing what is new; price changes of existing base plans and offers wait for the owner (--allow-price-changes)'); const held: HeldPrice[] = []; const failedSubs: string[] = []; for (const { s, body, diff, version, offerMissing, extraMissing, offerDrift } of todo) { try { 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(play, `${play.API}/subscriptions/${id}`), create: () => play.call('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' || (prune && diff.listingsRemoved?.length)) { const pruneLanguages = prune; if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) { held.push({ id: s.id, changes: diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) }); if (diff.listings.length || diff.grace || (prune && diff.listingsRemoved?.length)) { // the text only (listings, grace period); the base plans as Play has them const plans = cur!.basePlans?.map(({ state: _s, ...b }) => (diff.grace && b.basePlanId === basePlanId(s) ? { ...b, autoRenewingBasePlanType: { ...b.autoRenewingBasePlanType!, gracePeriodDuration: diff.grace.to } } : b)); await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings${diff.grace ? ',basePlans' : ''}®ionsVersion.version=${v}`, { ...playPatchSubscription(cur!, body, { pruneLanguages }), basePlans: plans }); log(TAG, `${s.id}: ${playSubTextChanges(diff, prune).join(' · ')} updated (prices unchanged)`); } } else { await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans®ionsVersion.version=${v}`, playPatchSubscription(cur!, body, { pruneLanguages })); 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 offerUrl = `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}/offers`; const createOffer = async (o: { type: string; usd?: number }, extra?: NonNullable[number]) => { const ob = await buildOffer(s, regions, o, extra); // 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); // offers that exist but differ: phases (text) are updated; their prices only behind the price gate for (const d of offerDrift) { if (d.prices.length && !liveOk) { held.push({ id: `${s.id} offer ${d.offerId}`, changes: d.prices }); continue; } await play.call('PATCH', `${offerUrl}/${encodeURIComponent(d.offerId)}?updateMask=phases®ionsVersion.version=${v}`, d.body); log(TAG, `${s.id}: offer ${d.offerId} updated (${[...d.phase, ...(d.prices.length ? [`${d.prices.length} price(s)`] : [])].join('; ')})`); } } catch (e) { const msg = (e as Error).message.replace(/\s+/g, ' ').slice(0, 300); const le = parseLimitError(msg); const cur = le ? body.basePlans?.[0]?.regionalConfigs?.find(r => r.regionCode === le.region)?.price?.currencyCode : undefined; if (le && cur && learnLimit(limits, le, cur)) { saveLimits(root, limits); warn(TAG, `${s.id}: Play limit for ${le.region} is ${le.min}-${le.max}, saved in .storebound/play-price-limits.json: run again and that region is ${skipOut ? 'left out' : 'clamped'}`); } warn(TAG, `${s.id}: FAILED: ${msg}; going on with the next subscription`); failedSubs.push(s.id); } } holdPriceChanges(held); if (failedSubs.length) { process.exitCode = 1; warn(TAG, `${failedSubs.length} of ${todo.length} subscription(s) failed (${failedSubs.join(', ')}); the others are written. Fix the cause, run --dry, then again with --yes.`); return; } done(TAG, held.length ? 'subscriptions done, price changes left for the owner' : 'subscriptions done'); }