diff --git a/packages/eas-cli/schema/metadata-0.json b/packages/eas-cli/schema/metadata-0.json index 0f72528ba1..8837d47848 100644 --- a/packages/eas-cli/schema/metadata-0.json +++ b/packages/eas-cli/schema/metadata-0.json @@ -800,6 +800,65 @@ "vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" } } }, + "ApplePricing": { + "type": "object", + "additionalProperties": false, + "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": { + "tier": { + "type": "string", + "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" } + ] + }, + "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"] + } + ] + } + } + }, "AppleAgeRatingOverride": { "enum": [ "NONE", @@ -1034,6 +1093,14 @@ "appClip": { "$ref": "#/definitions/apple/AppleAppClip", "description": "App Clip metadata. Only applies to apps that ship an App Clip target." + }, + "pricing": { + "$ref": "#/definitions/apple/ApplePricing", + "description": "App pricing configuration (price tier + scheduled changes)." + }, + "availability": { + "$ref": "#/definitions/apple/AppleAvailability", + "description": "Territory availability configuration." } } } diff --git a/packages/eas-cli/src/metadata/apple/config/reader.ts b/packages/eas-cli/src/metadata/apple/config/reader.ts index 0f32395477..31ac5b5720 100644 --- a/packages/eas-cli/src/metadata/apple/config/reader.ts +++ b/packages/eas-cli/src/metadata/apple/config/reader.ts @@ -20,8 +20,10 @@ import { AppleAppClip, AppleAppClipDefaultExperience, AppleAppClipLocalizedInfo, + AppleAvailability, AppleMetadata, ApplePreviews, + ApplePricing, AppleScreenshots, } from '../types'; @@ -257,4 +259,14 @@ export class AppleConfigReader { public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null { return this.schema.appClip?.defaultExperience?.info?.[locale] ?? 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; + } } diff --git a/packages/eas-cli/src/metadata/apple/config/writer.ts b/packages/eas-cli/src/metadata/apple/config/writer.ts index 5d444b19ae..2a51b1a870 100644 --- a/packages/eas-cli/src/metadata/apple/config/writer.ts +++ b/packages/eas-cli/src/metadata/apple/config/writer.ts @@ -17,8 +17,10 @@ import { AppleAppClipDefaultExperience, AppleAppClipLocalizedInfo, AppleAppClipReviewDetail, + AppleAvailability, AppleMetadata, ApplePreviews, + ApplePricing, AppleScreenshots, } from '../types'; @@ -241,6 +243,29 @@ export class AppleConfigWriter { } } + /** 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; + } + 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). */ 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..4d274e033e 100644 --- a/packages/eas-cli/src/metadata/apple/data.ts +++ b/packages/eas-cli/src/metadata/apple/data.ts @@ -6,6 +6,7 @@ import type { AppInfoData } from './tasks/app-info'; import type { AppReviewData } from './tasks/app-review-detail'; import type { AppVersionData } from './tasks/app-version'; import type { PreviewsData } from './tasks/previews'; +import type { PricingData } from './tasks/pricing'; import type { ScreenshotsData } from './tasks/screenshots'; /** @@ -18,7 +19,8 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData & AppReviewData & ScreenshotsData & PreviewsData & - AppClipData; + AppClipData & + PricingData; /** * The unprepared partial apple data, used within the `prepareAsync` tasks. diff --git a/packages/eas-cli/src/metadata/apple/tasks/__tests__/pricing.test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/pricing.test.ts new file mode 100644 index 0000000000..031dcf42d1 --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/__tests__/pricing.test.ts @@ -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'], + }); + }); + }); +}); diff --git a/packages/eas-cli/src/metadata/apple/tasks/index.ts b/packages/eas-cli/src/metadata/apple/tasks/index.ts index 06fadbdf01..335ec503ec 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/index.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/index.ts @@ -4,6 +4,7 @@ import { AppInfoTask } from './app-info'; import { AppReviewDetailTask } from './app-review-detail'; import { AppVersionOptions, AppVersionTask } from './app-version'; import { PreviewsTask } from './previews'; +import { PricingTask } from './pricing'; 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 PricingTask(), ]; } diff --git a/packages/eas-cli/src/metadata/apple/tasks/pricing.ts b/packages/eas-cli/src/metadata/apple/tasks/pricing.ts new file mode 100644 index 0000000000..cde8b9e3f2 --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/pricing.ts @@ -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 { + 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 { + // 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 { + 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 { + 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(); + 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; +} diff --git a/packages/eas-cli/src/metadata/apple/types.ts b/packages/eas-cli/src/metadata/apple/types.ts index 75574371df..3e0bc5a6d0 100644 --- a/packages/eas-cli/src/metadata/apple/types.ts +++ b/packages/eas-cli/src/metadata/apple/types.ts @@ -54,8 +54,65 @@ export interface AppleMetadata { review?: AppleReview; /** App Clip metadata. Only applies to apps that ship an App Clip target. */ appClip?: AppleAppClip; + /** Pricing configuration for the app (price tier, scheduled changes). */ + pricing?: ApplePricing; + /** Territory availability configuration for the app. */ + availability?: AppleAvailability; } +/** + * Pricing configuration for an app. + * + * 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 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}`;