mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3265f615c | ||
|
|
a577e22aee |
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<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,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(),
|
||||
];
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user