diff --git a/packages/eas-cli/schema/metadata-0.json b/packages/eas-cli/schema/metadata-0.json index 0f72528ba1..e5e689aea2 100644 --- a/packages/eas-cli/schema/metadata-0.json +++ b/packages/eas-cli/schema/metadata-0.json @@ -800,6 +800,39 @@ "vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" } } }, + "AppleInAppPurchase": { + "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"], + "properties": { + "productId": { + "type": "string", + "description": "Unique product identifier for the IAP. Acts as the primary key when reconciling against App Store Connect.", + "minLength": 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`." + } + } + }, "AppleAgeRatingOverride": { "enum": [ "NONE", @@ -1034,6 +1067,13 @@ "appClip": { "$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" + } } } } diff --git a/packages/eas-cli/src/metadata/apple/config/reader.ts b/packages/eas-cli/src/metadata/apple/config/reader.ts index 0f32395477..cf0b2eed2b 100644 --- a/packages/eas-cli/src/metadata/apple/config/reader.ts +++ b/packages/eas-cli/src/metadata/apple/config/reader.ts @@ -20,6 +20,7 @@ import { AppleAppClip, AppleAppClipDefaultExperience, AppleAppClipLocalizedInfo, + AppleInAppPurchase, AppleMetadata, ApplePreviews, AppleScreenshots, @@ -257,4 +258,14 @@ export class AppleConfigReader { public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null { 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; + } } diff --git a/packages/eas-cli/src/metadata/apple/config/writer.ts b/packages/eas-cli/src/metadata/apple/config/writer.ts index 5d444b19ae..160231ae1c 100644 --- a/packages/eas-cli/src/metadata/apple/config/writer.ts +++ b/packages/eas-cli/src/metadata/apple/config/writer.ts @@ -17,6 +17,7 @@ import { AppleAppClipDefaultExperience, AppleAppClipLocalizedInfo, AppleAppClipReviewDetail, + AppleInAppPurchase, AppleMetadata, ApplePreviews, AppleScreenshots, @@ -241,6 +242,25 @@ 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; + 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 } : {}), + })); + } + /** Set per-locale App Clip info (subtitle + header image). */ public setAppClipLocalizedInfo(locale: string, info: AppleAppClipLocalizedInfo): void { this.schema.appClip = this.schema.appClip ?? {}; diff --git a/packages/eas-cli/src/metadata/apple/data.ts b/packages/eas-cli/src/metadata/apple/data.ts index 6806b71448..f1e0168a4e 100644 --- a/packages/eas-cli/src/metadata/apple/data.ts +++ b/packages/eas-cli/src/metadata/apple/data.ts @@ -5,6 +5,7 @@ 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 { ScreenshotsData } from './tasks/screenshots'; @@ -18,7 +19,8 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData & AppReviewData & ScreenshotsData & PreviewsData & - AppClipData; + AppClipData & + InAppPurchasesData; /** * The unprepared partial apple data, used within the `prepareAsync` tasks. diff --git a/packages/eas-cli/src/metadata/apple/tasks/__tests__/in-app-purchases-test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/in-app-purchases-test.ts new file mode 100644 index 0000000000..51b3ab41a5 --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/__tests__/in-app-purchases-test.ts @@ -0,0 +1,284 @@ +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); + }); + }); +}); diff --git a/packages/eas-cli/src/metadata/apple/tasks/in-app-purchases.ts b/packages/eas-cli/src/metadata/apple/tasks/in-app-purchases.ts new file mode 100644 index 0000000000..00a6f53065 --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/in-app-purchases.ts @@ -0,0 +1,151 @@ +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; +}; + +/** + * 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 { + 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 { + 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 { + 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.}` + ); + } +} diff --git a/packages/eas-cli/src/metadata/apple/tasks/index.ts b/packages/eas-cli/src/metadata/apple/tasks/index.ts index 06fadbdf01..830af499d7 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/index.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/index.ts @@ -3,6 +3,7 @@ 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 { ScreenshotsTask } from './screenshots'; import { AppleTask } from '../task'; @@ -23,5 +24,6 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[ new ScreenshotsTask(), new PreviewsTask(), new AppClipTask(), + new InAppPurchasesTask(), ]; } diff --git a/packages/eas-cli/src/metadata/apple/types.ts b/packages/eas-cli/src/metadata/apple/types.ts index 75574371df..c416f55b6b 100644 --- a/packages/eas-cli/src/metadata/apple/types.ts +++ b/packages/eas-cli/src/metadata/apple/types.ts @@ -54,6 +54,56 @@ 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[]; +} + +/** + * In-App Purchase type values from App Store Connect API. + * + * 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. + */ +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; } /** App Clip action enum values from App Store Connect API */