Compare commits

..
Author SHA1 Message Date
evanbaconandClaude Opus 4.6 06dbc19a5a docs(metadata/pricing): add TODOs for schedule push via expo/third-party#147
Document the new API surface from expo/third-party#147 that will enable
schedule push once @expo/apple-utils is bumped:
- AppPriceSchedule.createAsync (replaces entire schedule, no merge)
- AppPricePoint.getForAppAsync (resolve tiers to price point IDs)
- App.getPriceScheduleAsync (read current schedule)

Update the schedule-not-pushed warning to reference the tracking PR.
Add comments clarifying territory availability uses the legacy path
which is separate from pricing in the modern ASC API.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 13:36:35 -07:00
evanbaconandClaude Opus 4.6 84fadc4eac feat(metadata): support pricing tier and territory availability
EAS metadata previously had no way to set the App Store price tier or
territory availability, so every new app sat at free / worldwide
forever. Add `pricing` and `availability` blocks to the metadata schema
plus a new `PricingTask` that pulls and pushes both via the legacy
`App.updateAsync({ appPriceTier, territories })` endpoint exposed by
@expo/apple-utils.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:39:35 -07:00
10 changed files with 639 additions and 540 deletions

No files matched your search

+58 -31
View File
@@ -800,36 +800,62 @@
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"AppleInAppPurchase": {
"ApplePricing": {
"type": "object",
"additionalProperties": false,
"description": "Basic listing of an In-App Purchase. Localizations, pricing, and review screenshots are intentionally out of scope for this schema iteration.",
"required": ["productId", "referenceName", "type"],
"description": "App pricing configuration. NOTE: Apple migrated to base-territory pricing in 2023. EAS metadata currently uses the legacy `appPriceTier` API exposed by @expo/apple-utils.",
"properties": {
"productId": {
"tier": {
"type": "string",
"description": "Unique product identifier for the IAP. Acts as the primary key when reconciling against App Store Connect.",
"minLength": 1
"description": "App Store price tier id (e.g. \"0\" for free, \"1\" for tier 1).",
"defaultSnippets": [
{ "label": "Free", "body": "0" },
{ "label": "Tier 1", "body": "1" }
]
},
"referenceName": {
"type": "string",
"description": "Internal reference name shown in App Store Connect.",
"minLength": 1
},
"type": {
"enum": [
"CONSUMABLE",
"NON_CONSUMABLE",
"NON_RENEWING_SUBSCRIPTION",
"AUTO_RENEWABLE_SUBSCRIPTION",
"AUTOMATICALLY_RENEWABLE_SUBSCRIPTION",
"FREE_SUBSCRIPTION"
],
"description": "In-App Purchase type."
},
"state": {
"type": "string",
"description": "Read-only review state from App Store Connect. Only populated by `metadata:pull`; ignored on `metadata:push`."
"schedule": {
"type": "array",
"description": "Future scheduled price changes. Each entry switches the app to the given tier on `startDate`. Currently parsed but not pushed (the underlying appPriceSchedules endpoint is not yet wrapped by @expo/apple-utils).",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["startDate", "tier"],
"properties": {
"startDate": {
"type": "string",
"description": "ISO-8601 date the new price tier takes effect.",
"format": "date-time"
},
"tier": {
"type": "string",
"description": "App Store price tier id (e.g. \"0\" for free)."
}
}
}
}
}
},
"AppleAvailability": {
"type": "object",
"additionalProperties": false,
"description": "Territory availability configuration. Use ISO-3166 alpha-3 codes (e.g. \"USA\", \"GBR\", \"JPN\") which match App Store Connect Territory ids.",
"properties": {
"territories": {
"description": "Territories the app is available in. Use the literal string \"all\" to make the app available worldwide.",
"oneOf": [
{
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"description": "ISO-3166 alpha-3 territory code (e.g. \"USA\")."
}
},
{
"type": "string",
"enum": ["all"]
}
]
}
}
},
@@ -1068,12 +1094,13 @@
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"inAppPurchases": {
"type": "array",
"description": "Basic listing of In-App Purchases. Currently limited to a declarative round-trip of productId / referenceName / type. Pull is supported; push only reports diffs because the underlying apple-utils API is read-only for now.",
"items": {
"$ref": "#/definitions/apple/AppleInAppPurchase"
}
"pricing": {
"$ref": "#/definitions/apple/ApplePricing",
"description": "App pricing configuration (price tier + scheduled changes)."
},
"availability": {
"$ref": "#/definitions/apple/AppleAvailability",
"description": "Territory availability configuration."
}
}
}
@@ -20,9 +20,10 @@ import {
AppleAppClip,
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleInAppPurchase,
AppleAvailability,
AppleMetadata,
ApplePreviews,
ApplePricing,
AppleScreenshots,
} from '../types';
@@ -259,13 +260,13 @@ export class AppleConfigReader {
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
}
/**
* Get the desired In-App Purchase listing from config, or null if the
* key is missing entirely. An empty array is preserved as an empty array
* (the user explicitly cleared their list) so callers can distinguish
* "unconfigured" from "intentionally empty".
*/
public getInAppPurchases(): AppleInAppPurchase[] | null {
return this.schema.inAppPurchases ?? null;
/** Get the pricing block, or null if not configured. */
public getPricing(): ApplePricing | null {
return this.schema.pricing ?? null;
}
/** Get the availability block, or null if not configured. */
public getAvailability(): AppleAvailability | null {
return this.schema.availability ?? null;
}
}
@@ -17,9 +17,10 @@ import {
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAppClipReviewDetail,
AppleInAppPurchase,
AppleAvailability,
AppleMetadata,
ApplePreviews,
ApplePricing,
AppleScreenshots,
} from '../types';
@@ -242,23 +243,27 @@ export class AppleConfigWriter {
}
}
/**
* Set the In-App Purchase listing. Sorts by `productId` for stable
* `metadata:pull` round-trips and drops the key entirely when there are
* no IAPs to avoid spamming `store.config.json` with empty arrays.
*/
public setInAppPurchases(entries: AppleInAppPurchase[]): void {
if (!entries || entries.length === 0) {
delete this.schema.inAppPurchases;
/** Set the pricing configuration (price tier + scheduled changes). */
public setPricing(pricing: ApplePricing | null): void {
if (!pricing || (pricing.tier == null && !pricing.schedule?.length)) {
delete this.schema.pricing;
return;
}
const sorted = [...entries].sort((a, b) => a.productId.localeCompare(b.productId));
this.schema.inAppPurchases = sorted.map(entry => ({
productId: entry.productId,
referenceName: entry.referenceName,
type: entry.type,
...(entry.state ? { state: entry.state } : {}),
}));
this.schema.pricing = {
tier: pricing.tier,
schedule: pricing.schedule && pricing.schedule.length > 0 ? pricing.schedule : undefined,
};
}
/** Set the territory availability configuration. */
public setAvailability(availability: AppleAvailability | null): void {
if (!availability || availability.territories == null) {
delete this.schema.availability;
return;
}
this.schema.availability = {
territories: availability.territories,
};
}
/** Set per-locale App Clip info (subtitle + header image). */
+2 -2
View File
@@ -5,8 +5,8 @@ import type { AppClipData } from './tasks/app-clip';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
import type { InAppPurchasesData } from './tasks/in-app-purchases';
import type { PreviewsData } from './tasks/previews';
import type { PricingData } from './tasks/pricing';
import type { ScreenshotsData } from './tasks/screenshots';
/**
@@ -20,7 +20,7 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
ScreenshotsData &
PreviewsData &
AppClipData &
InAppPurchasesData;
PricingData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
@@ -1,284 +0,0 @@
import { App, InAppPurchase, InAppPurchaseState, InAppPurchaseType } from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData, PartialAppleData } from '../../data';
import { InAppPurchasesTask } from '../in-app-purchases';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makeIap(
id: string,
attrs: {
productId: string;
referenceName: string;
inAppPurchaseType?: InAppPurchaseType;
state?: InAppPurchaseState;
}
): InAppPurchase {
return new InAppPurchase(requestContext, id, {
productId: attrs.productId,
referenceName: attrs.referenceName,
inAppPurchaseType: attrs.inAppPurchaseType ?? InAppPurchaseType.NON_CONSUMABLE,
state: attrs.state ?? InAppPurchaseState.APPROVED,
} as any);
}
const IAPS_EMPTY_RESPONSE = {
data: [],
links: {
self: 'https://appstoreconnect.apple.com/iris/v1/apps/stub-id/inAppPurchases',
},
meta: { paging: { total: 0, limit: 50 } },
};
describe(InAppPurchasesTask, () => {
afterEach(() => {
nock.cleanAll();
});
describe('prepareAsync', () => {
it('initializes an empty map when the app has no IAPs', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(/\/apps\/stub-id\/inAppPurchases/)
.reply(200, IAPS_EMPTY_RESPONSE);
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new InAppPurchasesTask().prepareAsync({ context });
expect(context.inAppPurchases).toBeDefined();
expect(context.inAppPurchases?.size).toBe(0);
expect(scope.isDone()).toBeTruthy();
});
it('warns and falls back to empty map when ASC errors out', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(/\/apps\/stub-id\/inAppPurchases/)
.reply(500, { errors: [{ status: '500', title: 'boom' }] });
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new InAppPurchasesTask().prepareAsync({ context });
expect(context.inAppPurchases?.size).toBe(0);
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('writes nothing when the inventory is empty', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: { inAppPurchases: new Map() } as AppleData,
});
expect(writer.setInAppPurchases).toBeCalledWith([]);
});
it('writes a single IAP entry when one is registered', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const iap = makeIap('iap-1', {
productId: 'com.example.coins',
referenceName: 'Coins',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
state: InAppPurchaseState.APPROVED,
});
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: {
inAppPurchases: new Map([[iap.attributes.productId, iap]]),
} as AppleData,
});
expect(writer.setInAppPurchases).toBeCalledWith([
{
productId: 'com.example.coins',
referenceName: 'Coins',
type: 'CONSUMABLE',
state: 'APPROVED',
},
]);
});
it('writes multiple IAP entries when several are registered', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const a = makeIap('iap-a', {
productId: 'com.example.a',
referenceName: 'A',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
});
const b = makeIap('iap-b', {
productId: 'com.example.b',
referenceName: 'B',
inAppPurchaseType: InAppPurchaseType.NON_CONSUMABLE,
});
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: {
inAppPurchases: new Map([
['com.example.a', a],
['com.example.b', b],
]),
} as AppleData,
});
expect(writer.setInAppPurchases).toBeCalledTimes(1);
const arg = writer.setInAppPurchases.mock.calls[0][0];
expect(arg).toHaveLength(2);
expect(arg.map((entry: any) => entry.productId).sort()).toEqual([
'com.example.a',
'com.example.b',
]);
});
});
describe('uploadAsync', () => {
it('skips when no IAPs are configured', async () => {
const reader = new AppleConfigReader({});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: { inAppPurchases: new Map() } as AppleData,
});
// No HTTP calls should occur — the task is read-only.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('treats an existing matching IAP as a no-op', async () => {
const existing = makeIap('iap-1', {
productId: 'com.example.coins',
referenceName: 'Coins',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
});
const reader = new AppleConfigReader({
inAppPurchases: [
{
productId: 'com.example.coins',
referenceName: 'Coins',
type: 'CONSUMABLE',
},
],
});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: {
inAppPurchases: new Map([['com.example.coins', existing]]),
} as AppleData,
});
// Read-only push: no HTTP calls regardless of state.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('reports a would-create when config has a new IAP', async () => {
const reader = new AppleConfigReader({
inAppPurchases: [
{
productId: 'com.example.new',
referenceName: 'New IAP',
type: 'NON_CONSUMABLE',
},
],
});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: { inAppPurchases: new Map() } as AppleData,
});
// No HTTP calls — current implementation only warns.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('reports a would-rename when referenceName differs', async () => {
const existing = makeIap('iap-1', {
productId: 'com.example.coins',
referenceName: 'Coins',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
});
const reader = new AppleConfigReader({
inAppPurchases: [
{
productId: 'com.example.coins',
referenceName: 'Gold Coins',
type: 'CONSUMABLE',
},
],
});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: {
inAppPurchases: new Map([['com.example.coins', existing]]),
} as AppleData,
});
// No HTTP calls — current implementation only warns.
expect(nock.pendingMocks()).toHaveLength(0);
});
});
describe('round-trip', () => {
it('preserves productId / referenceName / type from pull to push', async () => {
// Pull: fetch from ASC and write to a real (un-mocked) writer.
jest.unmock('../../config/writer');
const { AppleConfigWriter: RealWriter } = jest.requireActual(
'../../config/writer'
);
const writer = new RealWriter();
const iap = makeIap('iap-1', {
productId: 'com.example.gem',
referenceName: 'Gem',
inAppPurchaseType: InAppPurchaseType.NON_CONSUMABLE,
state: InAppPurchaseState.APPROVED,
});
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: {
inAppPurchases: new Map([[iap.attributes.productId, iap]]),
} as AppleData,
});
const schema = writer.toSchema();
expect(schema.apple.inAppPurchases).toEqual([
{
productId: 'com.example.gem',
referenceName: 'Gem',
type: 'NON_CONSUMABLE',
state: 'APPROVED',
},
]);
// Push: feed the schema back through the reader and confirm the
// existing IAP is treated as already in sync (no warnings, no calls).
const reader = new AppleConfigReader(schema.apple);
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: {
inAppPurchases: new Map([[iap.attributes.productId, iap]]),
} as AppleData,
});
expect(nock.pendingMocks()).toHaveLength(0);
});
});
});
@@ -0,0 +1,268 @@
import { App, AppPrice, AppPriceTier, Territory } from '@expo/apple-utils';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { PricingTask } from '../pricing';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makePrice(id: string, startDate: string, tierId: string): AppPrice {
const tier = new AppPriceTier(requestContext, tierId, {});
return new AppPrice(requestContext, id, {
startDate,
priceTier: tier,
} as any);
}
function makeTerritory(code: string, currency = 'USD'): Territory {
return new Territory(requestContext, code, { currency });
}
function makeApp(id = 'stub-id'): App {
return new App(requestContext, id, {} as any);
}
describe(PricingTask, () => {
describe('prepareAsync', () => {
it('populates appPrices and availableTerritories from App.infoAsync', async () => {
const app = makeApp();
const fetched = new App(requestContext, 'stub-id', {
prices: [makePrice('p1', '2024-01-01T00:00:00Z', '0')],
availableTerritories: [makeTerritory('USA'), makeTerritory('GBR', 'GBP')],
} as any);
const spy = jest.spyOn(App, 'infoAsync').mockResolvedValue(fetched);
const context: PartialAppleData = { app, projectDir: '/test' };
await new PricingTask().prepareAsync({ context });
expect(spy).toHaveBeenCalledWith(
app.context,
expect.objectContaining({
id: 'stub-id',
query: expect.objectContaining({
includes: expect.arrayContaining(['prices', 'availableTerritories']),
}),
})
);
expect(context.appPrices).toHaveLength(1);
expect(context.availableTerritories).toHaveLength(2);
spy.mockRestore();
});
it('initializes empty arrays when the API call fails', async () => {
const spy = jest
.spyOn(App, 'infoAsync')
.mockRejectedValue(new Error('legacy pricing endpoint not available'));
const context: PartialAppleData = { app: makeApp(), projectDir: '/test' };
await new PricingTask().prepareAsync({ context });
expect(context.appPrices).toEqual([]);
expect(context.availableTerritories).toEqual([]);
spy.mockRestore();
});
});
describe('downloadAsync', () => {
it('clears pricing and availability when the app has neither', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PricingTask().downloadAsync({
config: writer,
context: { appPrices: [], availableTerritories: [] } as any,
});
expect(writer.setPricing).toHaveBeenCalledWith(null);
expect(writer.setAvailability).toHaveBeenCalledWith(null);
});
it('writes the current price tier (free)', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PricingTask().downloadAsync({
config: writer,
context: {
appPrices: [makePrice('p1', '2020-01-01T00:00:00Z', '0')],
availableTerritories: [],
} as any,
});
expect(writer.setPricing).toHaveBeenCalledWith(
expect.objectContaining({ tier: '0', schedule: undefined })
);
});
it('writes future price changes as schedule entries', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const future = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString();
await new PricingTask().downloadAsync({
config: writer,
context: {
appPrices: [makePrice('p1', '2020-01-01T00:00:00Z', '0'), makePrice('p2', future, '5')],
availableTerritories: [],
} as any,
});
expect(writer.setPricing).toHaveBeenCalledWith(
expect.objectContaining({
tier: '0',
schedule: [{ startDate: future, tier: '5' }],
})
);
});
it('writes territory codes', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PricingTask().downloadAsync({
config: writer,
context: {
appPrices: [],
availableTerritories: [makeTerritory('USA'), makeTerritory('GBR', 'GBP')],
} as any,
});
expect(writer.setAvailability).toHaveBeenCalledWith({ territories: ['USA', 'GBR'] });
});
});
describe('uploadAsync', () => {
it('skips when neither pricing nor availability is configured', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).not.toHaveBeenCalled();
});
it('pushes the configured price tier', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({ pricing: { tier: '0' } }),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: '0',
territories: undefined,
});
});
it('pushes a paid price tier', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({ pricing: { tier: '5' } }),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledWith({ appPriceTier: '5', territories: undefined });
});
it('warns and skips push for scheduled price changes (pending expo/third-party#147)', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await new PricingTask().uploadAsync({
config: new AppleConfigReader({
pricing: {
tier: '0',
schedule: [{ startDate: '2099-01-01T00:00:00Z', tier: '5' }],
},
}),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
// Tier still gets pushed; schedule is logged as a warning.
expect(updateSpy).toHaveBeenCalledWith({ appPriceTier: '0', territories: undefined });
warnSpy.mockRestore();
});
it('pushes a single-territory availability list', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({ availability: { territories: ['USA'] } }),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: undefined,
territories: ['USA'],
});
});
it('pushes multiple territories, normalized and deduplicated', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({
availability: { territories: ['usa', 'GBR', 'usa', 'JPN'] },
}),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: undefined,
territories: ['USA', 'GBR', 'JPN'],
});
});
it('expands `all` to every supported territory', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
const territorySpy = jest
.spyOn(Territory, 'getAsync')
.mockResolvedValue([
makeTerritory('USA'),
makeTerritory('GBR', 'GBP'),
makeTerritory('JPN', 'JPY'),
]);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({ availability: { territories: 'all' } }),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(territorySpy).toHaveBeenCalled();
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: undefined,
territories: ['USA', 'GBR', 'JPN'],
});
territorySpy.mockRestore();
});
it('combines pricing and availability into a single update call', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({
pricing: { tier: '0' },
availability: { territories: ['USA', 'GBR'] },
}),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledTimes(1);
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: '0',
territories: ['USA', 'GBR'],
});
});
});
});
@@ -1,151 +0,0 @@
import { InAppPurchase } from '@expo/apple-utils';
import chalk from 'chalk';
import Log from '../../../log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
// TODO(expo/third-party#148): Once @expo/apple-utils is bumped to include the
// InAppPurchaseV2 and InAppPurchaseLocalization models, switch from v1
// read-only to v2 CRUD:
// - Use App.getInAppPurchasesV2Async() for listing instead of getInAppPurchasesAsync()
// - Use InAppPurchaseV2.createAsync() for creation
// - Use InAppPurchaseV2.updateAsync() for referenceName changes
// - Use InAppPurchaseLocalization for localized name/description round-trip
// (schema should add an optional `localizations` array per IAP entry)
//
// Note: auto-renewable subscriptions are a separate Apple resource
// (`subscriptionGroups` / `subscriptions`) and are intentionally out of scope
// for this task. The v2 InAppPurchaseV2Type enum only has CONSUMABLE,
// NON_CONSUMABLE, and NON_RENEWING_SUBSCRIPTION.
export type InAppPurchasesData = {
/**
* The list of in-app purchases registered for the app, keyed by `productId`.
* The map is empty when the app has no IAPs (or none are visible to the
* authenticated account).
*/
inAppPurchases: Map<string, InAppPurchase>;
};
/**
* Task for managing the basic listing of In-App Purchases (declarative
* round-trip of `productId`, `referenceName`, and `inAppPurchaseType`).
*
* Scope (intentional, see PR description):
* - Pull (download): writes the existing IAPs to `store.config.json`.
* - Push (upload): no-op for now. `@expo/apple-utils` only exposes
* read access to the deprecated v1 `inAppPurchases` resource — there is
* no create/update/delete and no v2 (`inAppPurchasesV2`) wrapper yet.
* When the user has IAPs in their config that don't exist (or differ) in
* ASC, we emit a warning so the round-trip is still informative without
* silently dropping intent.
*
* Explicitly out of scope for this iteration:
* - Localizations (no `InAppPurchaseLocalization` model in apple-utils)
* - Pricing, review screenshots, content hosting, family sharing
* - Deletes — we never delete IAPs that exist in ASC but are missing from
* config. Too dangerous for a first iteration.
*/
export class InAppPurchasesTask extends AppleTask {
public name = (): string => 'in-app purchases';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.inAppPurchases = new Map();
try {
const purchases = await context.app.getInAppPurchasesAsync();
for (const purchase of purchases) {
const productId = purchase.attributes.productId;
if (productId) {
context.inAppPurchases.set(productId, purchase);
}
}
} catch (error: any) {
// The IAP endpoint is on the iris API and may be unavailable for some
// accounts/apps (e.g. apps that have never had an IAP, or accounts
// without the right entitlements). Treat as empty rather than fatal.
Log.warn(
chalk`{yellow Skipped in-app purchases - failed to load from App Store Connect: ${error?.message ?? error}}`
);
}
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
const entries = Array.from(context.inAppPurchases.values()).map(purchase => ({
productId: purchase.attributes.productId,
referenceName: purchase.attributes.referenceName,
type: purchase.attributes.inAppPurchaseType as unknown as string,
state: (purchase.attributes.state as unknown as string) ?? undefined,
}));
config.setInAppPurchases(entries);
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const desired = config.getInAppPurchases();
if (!desired || desired.length === 0) {
Log.log(chalk`{dim - Skipped in-app purchases, none configured}`);
return;
}
// Match desired entries against the existing ASC inventory by productId.
// We never delete; we only report what would change. Apple's v1
// inAppPurchases endpoint exposed by @expo/apple-utils is read-only,
// so we can't actually create or patch records here.
const existing = context.inAppPurchases;
const toCreate: typeof desired = [];
const toUpdate: { productId: string; from: string; to: string }[] = [];
let unchanged = 0;
for (const entry of desired) {
const current = existing.get(entry.productId);
if (!current) {
toCreate.push(entry);
continue;
}
if (
entry.referenceName &&
current.attributes.referenceName !== entry.referenceName
) {
toUpdate.push({
productId: entry.productId,
from: current.attributes.referenceName,
to: entry.referenceName,
});
} else {
unchanged++;
}
}
if (unchanged > 0) {
Log.log(
chalk`{dim - In-app purchases: ${unchanged} already in sync}`
);
}
if (toCreate.length === 0 && toUpdate.length === 0) {
return;
}
// We intentionally only warn here. See class doc-comment.
Log.warn(
chalk`{yellow In-app purchase mutations are not yet supported by EAS metadata.}`
);
Log.warn(
chalk`{yellow IAP mutations require @expo/apple-utils with InAppPurchaseV2 support (see expo/third-party#148).}`
);
for (const entry of toCreate) {
Log.warn(
chalk` {yellow Would create IAP ${chalk.bold(entry.productId)} (${entry.type}) — ${entry.referenceName}}`
);
}
for (const entry of toUpdate) {
Log.warn(
chalk` {yellow Would rename IAP ${chalk.bold(entry.productId)}: ${entry.from} → ${entry.to}}`
);
}
Log.warn(
chalk`{yellow Apply these changes manually in App Store Connect for now.}`
);
}
}
@@ -3,8 +3,8 @@ import { AppClipTask } from './app-clip';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionOptions, AppVersionTask } from './app-version';
import { InAppPurchasesTask } from './in-app-purchases';
import { PreviewsTask } from './previews';
import { PricingTask } from './pricing';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
@@ -24,6 +24,6 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new ScreenshotsTask(),
new PreviewsTask(),
new AppClipTask(),
new InAppPurchasesTask(),
new PricingTask(),
];
}
@@ -0,0 +1,226 @@
import { App, AppPrice, Territory } from '@expo/apple-utils';
import chalk from 'chalk';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import { ApplePriceScheduleEntry, AppleTerritoryCode } from '../types';
/**
* Sentinel territory list meaning "make the app available in every supported
* App Store territory". Resolved at upload time against
* `Territory.getAsync` so we don't drift if Apple adds new territories.
*/
export const AVAILABILITY_ALL = 'all' as const;
export type PricingData = {
/**
* Current `AppPrice` records for the app. Each record encodes a (startDate,
* priceTier) pair — the active price tier is the most recent entry whose
* startDate is in the past.
*/
appPrices: AppPrice[];
/** Territories the app is currently available in. */
availableTerritories: Territory[];
};
/**
* Task for managing app pricing (price tier + scheduled changes) and
* territory availability. Apple groups these together because both are set
* via the legacy `App` PATCH endpoint that `@expo/apple-utils` exposes via
* `App.updateAsync({ appPriceTier, territories })`.
*
* Newer App Store Connect APIs (`appPriceSchedules`, `appAvailabilities`)
* supersede this for accounts on the post-2023 base-territory pricing model,
* but those endpoints are not yet wrapped by `@expo/apple-utils`. The reader
* accepts a `pricing.schedule` block that we can wire up once the new
* helpers land.
*
* TODO(expo/third-party#147): Once `@expo/apple-utils` is bumped to include
* the new pricing helpers, wire up schedule push:
*
* - Use `AppPriceSchedule.createAsync(context, { appId, baseTerritoryId, manualPrices })`
* to push the full schedule. Each `manualPrices` entry takes
* `{ appPricePointId, startDate?, endDate? }`.
*
* - IMPORTANT: `AppPriceSchedule.createAsync` REPLACES the entire prior
* schedule. When pushing, the full schedule from config must be sent --
* omitting entries will remove them from App Store Connect. There is no
* diff/merge behavior.
*
* - Use `AppPricePoint.getForAppAsync(context, appId, { query })` to
* resolve tier names to `appPricePointId` values, filterable by territory.
*
* - Use `App.getPriceScheduleAsync()` to read the current schedule (singular
* relationship: `apps/{id}/appPriceSchedule`).
*
* - Territory availability is separate from pricing in the modern API --
* it's managed via `TerritoryAvailability`, not via price schedules.
* The legacy `App.updateAsync({ territories })` path used below still
* works for setting availability.
*/
export class PricingTask extends AppleTask {
public name = (): string => 'pricing and availability';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.appPrices = [];
context.availableTerritories = [];
// Pull the current `prices` and `availableTerritories` relationships
// onto the in-memory App object. We refetch via `infoAsync` because the
// App used elsewhere in the flow is loaded without these includes.
let appWithPricing: App;
try {
appWithPricing = await App.infoAsync(context.app.context, {
id: context.app.id,
query: {
includes: ['prices', 'prices.priceTier', 'availableTerritories'],
},
});
} catch (error: any) {
// Pricing/availability are not always retrievable for every app state
// (e.g. apps removed from sale, or accounts on the new pricing model
// that 404 the legacy endpoints). Don't block the rest of the sync.
Log.warn(
chalk`{yellow Skipped pricing/availability prepare - failed to load: ${error?.message ?? error}}`
);
return;
}
context.appPrices = appWithPricing.attributes.prices ?? [];
context.availableTerritories = appWithPricing.attributes.availableTerritories ?? [];
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
// Pricing → schema.pricing
const sortedPrices = [...(context.appPrices ?? [])].sort((a, b) =>
(a.attributes.startDate ?? '').localeCompare(b.attributes.startDate ?? '')
);
if (sortedPrices.length === 0) {
config.setPricing(null);
} else {
// The "current" tier is the most recent entry whose startDate is in
// the past (or the earliest entry if everything is in the future).
const now = new Date().toISOString();
const past = sortedPrices.filter(p => (p.attributes.startDate ?? '') <= now);
const currentEntry = past.length > 0 ? past[past.length - 1] : sortedPrices[0];
const future = sortedPrices.filter(p => (p.attributes.startDate ?? '') > now);
const schedule: ApplePriceScheduleEntry[] = future.map(p => ({
startDate: p.attributes.startDate,
tier: p.attributes.priceTier?.id ?? '',
}));
config.setPricing({
tier: currentEntry.attributes.priceTier?.id,
schedule: schedule.length > 0 ? schedule : undefined,
});
}
// Availability → schema.availability
const territories = context.availableTerritories ?? [];
if (territories.length === 0) {
config.setAvailability(null);
} else {
config.setAvailability({
territories: territories.map(t => t.id),
});
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const pricing = config.getPricing();
const availability = config.getAvailability();
if (!pricing && !availability) {
Log.log(chalk`{dim - Skipped pricing and availability, not configured}`);
return;
}
if (pricing?.schedule && pricing.schedule.length > 0) {
// TODO(expo/third-party#147): Replace this warning with a call to
// AppPriceSchedule.createAsync once @expo/apple-utils is bumped.
// Remember: createAsync REPLACES the entire schedule, so the full
// config must be sent (not just the diff).
Log.warn(
chalk`{yellow pricing.schedule is not yet pushed. Scheduled price changes require a newer @expo/apple-utils with AppPriceSchedule support (see expo/third-party#147). Only pricing.tier is applied.}`
);
}
// Territory availability is set via the legacy App.updateAsync({ territories })
// endpoint below. The modern ASC API uses TerritoryAvailability as a separate
// resource from pricing (not part of appPriceSchedules). The legacy path works
// for both old and new pricing-model accounts.
let resolvedTerritories: AppleTerritoryCode[] | undefined;
if (availability?.territories) {
resolvedTerritories = await resolveTerritoriesAsync({
context,
desired: availability.territories,
});
}
const appPriceTier = pricing?.tier;
if (appPriceTier == null && resolvedTerritories == null) {
// Nothing to do — both blocks were configured but neither set a value
// we can act on (e.g. only `pricing.schedule` was provided).
return;
}
const summary: string[] = [];
if (appPriceTier != null) {
summary.push(`tier ${chalk.bold(appPriceTier)}`);
}
if (resolvedTerritories != null) {
summary.push(`${chalk.bold(resolvedTerritories.length)} territories`);
}
context.app = await logAsync(
() =>
context.app.updateAsync({
appPriceTier,
territories: resolvedTerritories,
}),
{
pending: `Updating pricing and availability (${summary.join(', ')})...`,
success: `Updated pricing and availability (${summary.join(', ')})`,
failure: 'Failed to update pricing and availability',
}
);
}
}
/**
* Resolve a desired territory list (either an explicit code list or the
* `'all'` sentinel) into an array of ISO territory codes the App Store
* Connect API will accept.
*/
async function resolveTerritoriesAsync({
context,
desired,
}: {
context: { app: App };
desired: AppleTerritoryCode[] | typeof AVAILABILITY_ALL;
}): Promise<AppleTerritoryCode[]> {
if (desired === AVAILABILITY_ALL) {
const territories = await Territory.getAsync(context.app.context, {
query: { limit: 200 },
});
return territories.map(t => t.id);
}
// Deduplicate, drop empties, and uppercase to match ASC's canonical form.
const seen = new Set<string>();
const out: string[] = [];
for (const code of desired) {
if (!code) {
continue;
}
const normalized = code.toUpperCase();
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
out.push(normalized);
}
return out;
}
+52 -45
View File
@@ -54,58 +54,65 @@ export interface AppleMetadata {
review?: AppleReview;
/** App Clip metadata. Only applies to apps that ship an App Clip target. */
appClip?: AppleAppClip;
/**
* In-App Purchase listing. Currently limited to a declarative round-trip
* of `productId`, `referenceName`, and `type`. Localizations, pricing,
* and review screenshots are intentionally out of scope for now.
*
* Note: auto-renewable subscriptions are a separate Apple resource
* (`subscriptionGroups` / `subscriptions`) and are intentionally out of
* scope for this task. They may appear here as legacy v1 entries on pull
* but cannot be created/managed via IAP APIs.
*
* TODO: Once apple-utils is bumped with InAppPurchaseLocalization
* (expo/third-party#148), add an optional `localizations` array per IAP
* entry for localized name/description round-trip.
*/
inAppPurchases?: AppleInAppPurchase[];
/** Pricing configuration for the app (price tier, scheduled changes). */
pricing?: ApplePricing;
/** Territory availability configuration for the app. */
availability?: AppleAvailability;
}
/**
* In-App Purchase type values from App Store Connect API.
* Pricing configuration for an app.
*
* The v2 `inAppPurchasesV2` resource only supports CONSUMABLE, NON_CONSUMABLE,
* and NON_RENEWING_SUBSCRIPTION. Auto-renewable subscriptions are a completely
* separate Apple resource (`subscriptionGroups` / `subscriptions`) and are NOT
* part of the IAP v2 API. See expo/third-party#148 for details.
*
* The legacy v1 types are preserved so that `metadata:pull` can still surface
* existing auto-renewable and free subscription IAPs that were created before
* the v2 split.
* NOTE: Apple migrated to "base territory" pricing in 2023, replacing the
* legacy global price tier model. The App Store Connect API still exposes a
* `priceTier` concept on the legacy `appPrices`/`appPriceTiers` resources,
* which is what `@expo/apple-utils` currently exposes via
* `App.updateAsync({ appPriceTier })`. Newer apps may need to use
* `appPriceSchedules` + `appPricePoints` (which require selecting a base
* territory and a price point id), but those endpoints are not yet wrapped by
* `@expo/apple-utils`. We use the legacy field for now and document this in
* the PR. The schema is intentionally forward-compatible.
*/
export type AppleInAppPurchaseType =
/** v2 IAP types (supported for CRUD once apple-utils is bumped) */
| 'CONSUMABLE'
| 'NON_CONSUMABLE'
| 'NON_RENEWING_SUBSCRIPTION'
/** @deprecated v1 legacy — auto-renewable subscriptions live on `subscriptionGroups`/`subscriptions` in v2 */
| 'AUTO_RENEWABLE_SUBSCRIPTION'
/** @deprecated v1 legacy — same as AUTO_RENEWABLE_SUBSCRIPTION, apple-utils v1 spelling */
| 'AUTOMATICALLY_RENEWABLE_SUBSCRIPTION'
/** @deprecated v1 legacy */
| 'FREE_SUBSCRIPTION';
export interface AppleInAppPurchase {
/** Product identifier (primary key, must be unique within the app). */
productId: string;
/** Internal reference name shown in App Store Connect. */
referenceName: string;
/** IAP type. */
type: AppleInAppPurchaseType | string;
/** Read-only review/state from App Store Connect. Only populated on pull. */
state?: string;
export interface ApplePricing {
/**
* App Store price tier (e.g. `'0'` for free, `'1'` for tier 1). When
* unset on a brand new app, the App Store defaults to free (tier 0).
*/
tier?: string;
/**
* Future scheduled price changes. Each entry switches the app to the
* given tier on `startDate`. The first entry that matches "now" or earlier
* is treated as the current price. This block is currently parsed but not
* pushed (the legacy `App.updateAsync` endpoint exposed by
* `@expo/apple-utils` only sets the active tier).
*/
schedule?: ApplePriceScheduleEntry[];
}
export interface ApplePriceScheduleEntry {
/** ISO-8601 date the new price tier takes effect. */
startDate: string;
/** App Store price tier id (e.g. `'0'` for free). */
tier: string;
}
/**
* Territory availability configuration. Use ISO-3166 alpha-3 codes (e.g.
* `"USA"`, `"GBR"`, `"JPN"`) — these match the App Store Connect Territory
* resource ids.
*/
export interface AppleAvailability {
/**
* The territories the app is available in. Use the literal string `'all'`
* to make the app available worldwide (every territory currently supported
* by App Store Connect). Omitting this field leaves availability untouched.
*/
territories?: AppleTerritoryCode[] | 'all';
}
/** ISO-3166 alpha-3 territory code (e.g. `"USA"`, `"GBR"`). */
export type AppleTerritoryCode = string;
/** App Clip action enum values from App Store Connect API */
export type AppleAppClipAction = `${AppClipAction}`;