Subscriptions drift on both stores (Apple prices in the dry run, localizations, group names, attributes, territories, intro offers reported; Play offer phases and prices, other-regions price, dropped regions gated, grace period, --prune-languages); pitfall 104, CHANGELOG; test fixtures for the new reads
This commit is contained in:
parent
e381bc2256
commit
abc195aee6
8 changed files with 558 additions and 59 deletions
|
|
@ -20,8 +20,8 @@ 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 {
|
||||
allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions,
|
||||
playSubscriptionBody,
|
||||
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';
|
||||
|
|
@ -58,7 +58,19 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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[] }[] = [];
|
||||
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<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 };
|
||||
}
|
||||
return playOfferBody(pkg, s, regions, offerRegions, extra);
|
||||
};
|
||||
|
||||
interface OfferDrift { offerId: string; phase: string[]; prices: HeldPrice['changes']; body: ReturnType<typeof playOfferBody> }
|
||||
const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType<typeof playSubDiff>; version: string; offerMissing: boolean; extraMissing: string[]; offerDrift: OfferDrift[] }[] = [];
|
||||
for (const [i, s] of subs.entries()) {
|
||||
const conv = await convert(s.usd);
|
||||
const regions = playSubRegions(s, conv.convertedRegionPrices, csv);
|
||||
|
|
@ -67,42 +79,63 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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?: { 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 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<typeof s.offers>[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`
|
||||
: [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';
|
||||
: [...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`);
|
||||
work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing });
|
||||
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.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length).map(w => ({ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) })), ctx.args.has('allow-price-changes'));
|
||||
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);
|
||||
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 => w.diff.basePlan === 'changed' && w.diff.priceChanges.length);
|
||||
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.map(w => ({ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) })),
|
||||
`Change the base plan prices 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 wait for the owner (--allow-price-changes)');
|
||||
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[] = [];
|
||||
|
||||
for (const { s, body, diff, version, offerMissing, extraMissing } of todo) {
|
||||
for (const { s, body, diff, version, offerMissing, extraMissing, offerDrift } of todo) {
|
||||
const v = encodeURIComponent(version);
|
||||
const id = encodeURIComponent(s.id);
|
||||
const cur = existing.get(s.id);
|
||||
|
|
@ -113,16 +146,19 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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') {
|
||||
} 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) {
|
||||
// the listings only; the base plans as Play has them
|
||||
await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings®ionsVersion.version=${v}`, { ...playPatchSubscription(cur!, body), basePlans: cur!.basePlans?.map(({ state: _s, ...b }) => b) });
|
||||
log(TAG, `${s.id}: listings updated (prices unchanged)`);
|
||||
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));
|
||||
await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans®ionsVersion.version=${v}`, playPatchSubscription(cur!, body, { pruneLanguages }));
|
||||
log(TAG, `${s.id}: updated`);
|
||||
}
|
||||
}
|
||||
|
|
@ -131,14 +167,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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<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`;
|
||||
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)}`),
|
||||
|
|
@ -149,6 +180,12 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
};
|
||||
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('; ')})`);
|
||||
}
|
||||
}
|
||||
holdPriceChanges(held);
|
||||
done(TAG, held.length ? 'subscriptions done, price changes left for the owner' : 'subscriptions done');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue