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
|
||||
// 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).
|
||||
// storebound apple iap [--dry] [--only id1,id2]
|
||||
// existing products are left alone; a price that differs from the config (USA base and overrides) is shown in the plan
|
||||
// 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,
|
||||
// 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
|
||||
|
|
@ -18,6 +19,7 @@ import { appleTerritory, nearestPrices, samePrice } from '../lib/money';
|
|||
import { createOnce, findOrCreate, getById } from '../lib/upsert';
|
||||
import { ascClient, type J } from './asc';
|
||||
import * as rt from '../lib/rt';
|
||||
import { appleCurrent } from '../prices';
|
||||
|
||||
/** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */
|
||||
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(', ')})`);
|
||||
}
|
||||
|
||||
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 created: string[] = [];
|
||||
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`);
|
||||
if (!sched?.included?.length) {
|
||||
const manual = [{ territory: 'USA', price: p.usd }, ...(p.overrides ?? []).map(o => ({ territory: appleTerritory(o), price: o.price }))];
|
||||
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 manual = wanted(p);
|
||||
await writeSchedule(id, manual);
|
||||
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`);
|
||||
|
|
@ -120,7 +136,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const add = locales.filter(l => !have.has(l));
|
||||
const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`);
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,31 @@
|
|||
"keychain",
|
||||
"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-submit": [
|
||||
"new-version",
|
||||
|
|
@ -144,8 +168,10 @@
|
|||
"reconvert",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
"update-bigmac",
|
||||
"with-automatic"
|
||||
"with-automatic",
|
||||
"write"
|
||||
],
|
||||
"apple ppo": [
|
||||
"experiment",
|
||||
|
|
@ -253,8 +279,10 @@
|
|||
"reconvert",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
"update-bigmac",
|
||||
"with-automatic"
|
||||
"with-automatic",
|
||||
"write"
|
||||
],
|
||||
"apple testflight": [
|
||||
"build",
|
||||
|
|
@ -309,8 +337,10 @@
|
|||
"reconvert",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
"update-bigmac",
|
||||
"with-automatic"
|
||||
"with-automatic",
|
||||
"write"
|
||||
],
|
||||
"apps": [
|
||||
"app-id",
|
||||
|
|
@ -683,8 +713,10 @@
|
|||
"reconvert",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
"update-bigmac",
|
||||
"with-automatic"
|
||||
"with-automatic",
|
||||
"write"
|
||||
],
|
||||
"play upload": [
|
||||
"include-drafts",
|
||||
|
|
@ -717,8 +749,10 @@
|
|||
"reconvert",
|
||||
"source",
|
||||
"store",
|
||||
"threshold",
|
||||
"update-bigmac",
|
||||
"with-automatic"
|
||||
"with-automatic",
|
||||
"write"
|
||||
],
|
||||
"privacy-manifest": [
|
||||
"ios",
|
||||
|
|
@ -1262,7 +1296,7 @@
|
|||
"storebound apple game-center [status] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"apple iap": [
|
||||
"storebound apple iap [--dry] [--only id1,id2]"
|
||||
"storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]"
|
||||
],
|
||||
"apple iap-review": [
|
||||
"storebound apple iap-review [--dry]"
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const a = ctx.args;
|
||||
const sub = a._[0];
|
||||
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');
|
||||
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
||||
const useApple = (!store || store === 'apple') && !!config.apple;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue