apple iap: the dry run shows a live price that differs from the config (USA base and overrides) and --allow-price-changes replaces the price schedule; prices diff (the catalog diff report)
This commit is contained in:
parent
cbba936ed8
commit
4b0f9cd5b8
3 changed files with 75 additions and 21 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
// In-app purchases on App Store Connect: create missing products, localizations, price schedule (USA base + your
|
// In-app purchases on App Store Connect: create missing products, localizations, price schedule (USA base + your
|
||||||
// overrides; Apple equalizes every other storefront) and availability (every territory + new ones). Idempotent:
|
// overrides; Apple equalizes every other storefront) and availability (every territory + new ones). Idempotent:
|
||||||
// existing products and schedules are left alone (prices of an existing schedule are not changed; do that in the UI).
|
// existing products are left alone; a price that differs from the config (USA base and overrides) is shown in the plan
|
||||||
// storebound apple iap [--dry] [--only id1,id2]
|
// and changed only with --allow-price-changes (live prices are the owner's call; Acme 2026-10-22: the plan was silent).
|
||||||
|
// storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]
|
||||||
// Then `apple iap-review` for the review screenshots. First IAPs can only be submitted together with an app version,
|
// Then `apple iap-review` for the review screenshots. First IAPs can only be submitted together with an app version,
|
||||||
// from the IAP page in the web UI (see the store-iap-setup skill).
|
// from the IAP page in the web UI (see the store-iap-setup skill).
|
||||||
// --dry reads everything (read-only) and prints one plan row per product. After a real run the store's product list
|
// --dry reads everything (read-only) and prints one plan row per product. After a real run the store's product list
|
||||||
|
|
@ -18,6 +19,7 @@ import { appleTerritory, nearestPrices, samePrice } from '../lib/money';
|
||||||
import { createOnce, findOrCreate, getById } from '../lib/upsert';
|
import { createOnce, findOrCreate, getById } from '../lib/upsert';
|
||||||
import { ascClient, type J } from './asc';
|
import { ascClient, type J } from './asc';
|
||||||
import * as rt from '../lib/rt';
|
import * as rt from '../lib/rt';
|
||||||
|
import { appleCurrent } from '../prices';
|
||||||
|
|
||||||
/** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */
|
/** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */
|
||||||
export const APPLE_IAP_TYPE = { consumable: 'CONSUMABLE', non_consumable: 'NON_CONSUMABLE', non_renewing: 'NON_RENEWING_SUBSCRIPTION' } as const;
|
export const APPLE_IAP_TYPE = { consumable: 'CONSUMABLE', non_consumable: 'NON_CONSUMABLE', non_renewing: 'NON_RENEWING_SUBSCRIPTION' } as const;
|
||||||
|
|
@ -42,6 +44,24 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
throw new Error(`${territory} has no ${price} price point (nearest ${nearestPrices(seen, price).join(', ')})`);
|
throw new Error(`${territory} has no ${price} price point (nearest ${nearestPrices(seen, price).join(', ')})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wanted = (p: (typeof products)[number]) => [{ territory: 'USA', price: p.usd }, ...(p.overrides ?? []).map(o => ({ territory: appleTerritory(o), price: o.price }))];
|
||||||
|
/** "USA 9.99 -> 12.99" per territory whose live (current, not scheduled) manual price differs from the config. */
|
||||||
|
async function priceDiffs(id: string, p: (typeof products)[number]): Promise<string[]> {
|
||||||
|
const live = (await appleCurrent(c, id, p.id, false)).rows.filter(x => !x.startDate || x.startDate <= new Date().toISOString().slice(0, 10));
|
||||||
|
return wanted(p).flatMap(w => { const cur = live.find(x => x.territory === w.territory); return cur && !samePrice(cur.price, w.price) ? [`${w.territory} ${cur.price} -> ${w.price}`] : []; });
|
||||||
|
}
|
||||||
|
async function writeSchedule(id: string, manual: { territory: string; price: number }[]): Promise<void> {
|
||||||
|
const points = await Promise.all(manual.map(m => pricePoint(id, m.territory, m.price)));
|
||||||
|
await c.asc('POST', '/v1/inAppPurchasePriceSchedules', {
|
||||||
|
data: { type: 'inAppPurchasePriceSchedules', relationships: {
|
||||||
|
inAppPurchase: { data: { type: 'inAppPurchases', id } },
|
||||||
|
baseTerritory: { data: { type: 'territories', id: 'USA' } },
|
||||||
|
manualPrices: { data: manual.map(m => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}' })) } } },
|
||||||
|
included: manual.map((m, k) => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}', attributes: { startDate: null },
|
||||||
|
relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: points[k] } } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const allowPrices = ctx.args.has('allow-price-changes');
|
||||||
const rows: PlanRow[] = [];
|
const rows: PlanRow[] = [];
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
for (const [i, p] of products.entries()) {
|
for (const [i, p] of products.entries()) {
|
||||||
|
|
@ -76,17 +96,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
|
||||||
const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`);
|
const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`);
|
||||||
if (!sched?.included?.length) {
|
if (!sched?.included?.length) {
|
||||||
const manual = [{ territory: 'USA', price: p.usd }, ...(p.overrides ?? []).map(o => ({ territory: appleTerritory(o), price: o.price }))];
|
const manual = wanted(p);
|
||||||
const points = await Promise.all(manual.map(m => pricePoint(id, m.territory, m.price)));
|
await writeSchedule(id, manual);
|
||||||
await c.asc('POST', '/v1/inAppPurchasePriceSchedules', {
|
|
||||||
data: { type: 'inAppPurchasePriceSchedules', relationships: {
|
|
||||||
inAppPurchase: { data: { type: 'inAppPurchases', id } },
|
|
||||||
baseTerritory: { data: { type: 'territories', id: 'USA' } },
|
|
||||||
manualPrices: { data: manual.map(m => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}' })) } } },
|
|
||||||
included: manual.map((m, k) => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}', attributes: { startDate: null },
|
|
||||||
relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: points[k] } } } })),
|
|
||||||
});
|
|
||||||
log('apple', ` prices ${manual.map(m => `${m.territory} ${m.price}`).join(', ')}`);
|
log('apple', ` prices ${manual.map(m => `${m.territory} ${m.price}`).join(', ')}`);
|
||||||
|
} else {
|
||||||
|
const diff = await priceDiffs(id, p);
|
||||||
|
if (diff.length && allowPrices) { await writeSchedule(id, wanted(p)); log('apple', ` prices changed: ${diff.join(', ')} (takes effect today; Apple equalizes the other storefronts)`); }
|
||||||
|
else if (diff.length) log('apple', ` prices differ from the config, NOT changed: ${diff.join(', ')} (live prices: re-run with --allow-price-changes after the owner's yes)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`);
|
const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`);
|
||||||
|
|
@ -120,7 +136,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const add = locales.filter(l => !have.has(l));
|
const add = locales.filter(l => !have.has(l));
|
||||||
const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`);
|
const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`);
|
||||||
const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`);
|
const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`);
|
||||||
const parts = [add.length ? `localizations ${add.join(', ')}` : '', sched?.included?.length ? '' : `prices ${prices}`, avail?.data ? '' : `availability ${territories.length} territories + new ones`].filter(Boolean);
|
const diff = sched?.included?.length ? await priceDiffs(id, p) : [];
|
||||||
|
const priceNote = !sched?.included?.length ? `prices ${prices}` : diff.length ? `PRICE CHANGE ${diff.join(', ')}${allowPrices ? '' : ' (only with --allow-price-changes)'}` : '';
|
||||||
|
const parts = [add.length ? `localizations ${add.join(', ')}` : '', priceNote, avail?.data ? '' : `availability ${territories.length} territories + new ones`].filter(Boolean);
|
||||||
return parts.length ? { item: p.id, action: 'update', detail: `${parts.join('; ')} (state ${state ?? '?'})` } : { item: p.id, action: 'keep' };
|
return parts.length ? { item: p.id, action: 'update', detail: `${parts.join('; ')} (state ${state ?? '?'})` } : { item: p.id, action: 'keep' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,31 @@
|
||||||
"keychain",
|
"keychain",
|
||||||
"no-install"
|
"no-install"
|
||||||
],
|
],
|
||||||
"apple iap": [],
|
"apple iap": [
|
||||||
|
"all",
|
||||||
|
"allow-price-changes",
|
||||||
|
"base",
|
||||||
|
"ceiling",
|
||||||
|
"csv",
|
||||||
|
"file",
|
||||||
|
"floor",
|
||||||
|
"include-high-income",
|
||||||
|
"max-level",
|
||||||
|
"max-ratio",
|
||||||
|
"min-ratio",
|
||||||
|
"no-only-decreases",
|
||||||
|
"no-subscriptions",
|
||||||
|
"offline",
|
||||||
|
"only-decreases",
|
||||||
|
"out",
|
||||||
|
"reconvert",
|
||||||
|
"source",
|
||||||
|
"store",
|
||||||
|
"threshold",
|
||||||
|
"update-bigmac",
|
||||||
|
"with-automatic",
|
||||||
|
"write"
|
||||||
|
],
|
||||||
"apple iap-review": [],
|
"apple iap-review": [],
|
||||||
"apple iap-submit": [
|
"apple iap-submit": [
|
||||||
"new-version",
|
"new-version",
|
||||||
|
|
@ -144,8 +168,10 @@
|
||||||
"reconvert",
|
"reconvert",
|
||||||
"source",
|
"source",
|
||||||
"store",
|
"store",
|
||||||
|
"threshold",
|
||||||
"update-bigmac",
|
"update-bigmac",
|
||||||
"with-automatic"
|
"with-automatic",
|
||||||
|
"write"
|
||||||
],
|
],
|
||||||
"apple ppo": [
|
"apple ppo": [
|
||||||
"experiment",
|
"experiment",
|
||||||
|
|
@ -253,8 +279,10 @@
|
||||||
"reconvert",
|
"reconvert",
|
||||||
"source",
|
"source",
|
||||||
"store",
|
"store",
|
||||||
|
"threshold",
|
||||||
"update-bigmac",
|
"update-bigmac",
|
||||||
"with-automatic"
|
"with-automatic",
|
||||||
|
"write"
|
||||||
],
|
],
|
||||||
"apple testflight": [
|
"apple testflight": [
|
||||||
"build",
|
"build",
|
||||||
|
|
@ -309,8 +337,10 @@
|
||||||
"reconvert",
|
"reconvert",
|
||||||
"source",
|
"source",
|
||||||
"store",
|
"store",
|
||||||
|
"threshold",
|
||||||
"update-bigmac",
|
"update-bigmac",
|
||||||
"with-automatic"
|
"with-automatic",
|
||||||
|
"write"
|
||||||
],
|
],
|
||||||
"apps": [
|
"apps": [
|
||||||
"app-id",
|
"app-id",
|
||||||
|
|
@ -683,8 +713,10 @@
|
||||||
"reconvert",
|
"reconvert",
|
||||||
"source",
|
"source",
|
||||||
"store",
|
"store",
|
||||||
|
"threshold",
|
||||||
"update-bigmac",
|
"update-bigmac",
|
||||||
"with-automatic"
|
"with-automatic",
|
||||||
|
"write"
|
||||||
],
|
],
|
||||||
"play upload": [
|
"play upload": [
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
|
|
@ -717,8 +749,10 @@
|
||||||
"reconvert",
|
"reconvert",
|
||||||
"source",
|
"source",
|
||||||
"store",
|
"store",
|
||||||
|
"threshold",
|
||||||
"update-bigmac",
|
"update-bigmac",
|
||||||
"with-automatic"
|
"with-automatic",
|
||||||
|
"write"
|
||||||
],
|
],
|
||||||
"privacy-manifest": [
|
"privacy-manifest": [
|
||||||
"ios",
|
"ios",
|
||||||
|
|
@ -1262,7 +1296,7 @@
|
||||||
"storebound apple game-center [status] [--dry] [--yes] [--json]"
|
"storebound apple game-center [status] [--dry] [--yes] [--json]"
|
||||||
],
|
],
|
||||||
"apple iap": [
|
"apple iap": [
|
||||||
"storebound apple iap [--dry] [--only id1,id2]"
|
"storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]"
|
||||||
],
|
],
|
||||||
"apple iap-review": [
|
"apple iap-review": [
|
||||||
"storebound apple iap-review [--dry]"
|
"storebound apple iap-review [--dry]"
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const a = ctx.args;
|
const a = ctx.args;
|
||||||
const sub = a._[0];
|
const sub = a._[0];
|
||||||
if (sub === 'ppp') return (await import('./prices-ppp')).run(ctx);
|
if (sub === 'ppp') return (await import('./prices-ppp')).run(ctx);
|
||||||
if (sub !== 'export' && sub !== 'import') throw new Error('usage: storebound prices export | import [--csv store/prices.csv] [--dry] | ppp [--base <USD>] [--dry]');
|
// `prices diff`: the config's prices against both stores (the same read-only report as `catalog diff`)
|
||||||
|
if (sub === 'diff') { ctx.args._.splice(0, 1, 'diff'); return (await import('./catalog')).run(ctx); }
|
||||||
|
if (sub !== 'export' && sub !== 'import') throw new Error('usage: storebound prices export | import [--csv store/prices.csv] [--dry] | diff | ppp [--base <USD>] [--dry]');
|
||||||
const store = a.get('store');
|
const store = a.get('store');
|
||||||
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
||||||
const useApple = (!store || store === 'apple') && !!config.apple;
|
const useApple = (!store || store === 'apple') && !!config.apple;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue