From 3c0e9ec4ad050f53ac34dcfa202df17b4651d67b Mon Sep 17 00:00:00 2001 From: slamhan Date: Wed, 23 Sep 2026 18:06:57 +0800 Subject: [PATCH 1/2] feat(apps): list app price points and set the app's price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `asc apps price-points list --app-id [--territory USA]` lists the prices an app can be sold at (all pages; the 0.0 point makes it free) and `asc apps prices set --app-id --base-territory --price-point-id` creates the app's price schedule, which Apple equalizes worldwide — the fix for the "not eligible for submission until pricing has been set" refusal. REST: GET /api/v1/apps/:appId/price-points, POST /api/v1/apps/:appId/prices/set. App gains a listPricePoints affordance. Verified live (read-only): 801 USA price points over 5 pages, JPN, and the REST route; setting a price was not run. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + README.md | 4 +- .../Commands/Apps/AppsCommand.swift | 2 +- .../Apps/AppsPricePointsCommand.swift | 9 + .../Commands/Apps/AppsPricePointsList.swift | 28 +++ .../Commands/Apps/AppsPricesCommand.swift | 9 + .../Commands/Apps/AppsPricesSet.swift | 31 +++ .../Controllers/AppPricingController.swift | 36 +++ .../ASCCommand/Commands/Web/RESTRoutes.swift | 3 + Sources/Domain/Apps/App.swift | 2 + .../Domain/Apps/Pricing/AppPricePoint.swift | 63 +++++ .../Apps/Pricing/AppPriceSchedule.swift | 25 ++ .../Apps/Pricing/AppPricing+RESTRoutes.swift | 7 + .../Apps/Pricing/PricingRepository.swift | 4 + Sources/Domain/Shared/RESTPathResolver.swift | 1 + .../Apps/Pricing/SDKPricingRepository.swift | 40 ++++ .../Apps/AppPricingCommandsTests.swift | 69 ++++++ .../Commands/Apps/AppsListTests.swift | 2 + .../Commands/Apps/AppsUpdateTests.swift | 1 + .../Commands/Web/RESTRoutesTests.swift | 16 ++ .../OutputFormatterTests.swift | 2 + .../Apps/Pricing/AppPricingTests.swift | 44 ++++ .../Pricing/SDKPricingRepositoryTests.swift | 40 ++++ docs/features/app-pricing.md | 221 ++++++++++++++++++ docs/features/submit-with-products.md | 2 +- 25 files changed, 659 insertions(+), 3 deletions(-) create mode 100644 Sources/ASCCommand/Commands/Apps/AppsPricePointsCommand.swift create mode 100644 Sources/ASCCommand/Commands/Apps/AppsPricePointsList.swift create mode 100644 Sources/ASCCommand/Commands/Apps/AppsPricesCommand.swift create mode 100644 Sources/ASCCommand/Commands/Apps/AppsPricesSet.swift create mode 100644 Sources/ASCCommand/Commands/Web/Controllers/AppPricingController.swift create mode 100644 Sources/Domain/Apps/Pricing/AppPricePoint.swift create mode 100644 Sources/Domain/Apps/Pricing/AppPriceSchedule.swift create mode 100644 Sources/Domain/Apps/Pricing/AppPricing+RESTRoutes.swift create mode 100644 Tests/ASCCommandTests/Commands/Apps/AppPricingCommandsTests.swift create mode 100644 Tests/DomainTests/Apps/Pricing/AppPricingTests.swift create mode 100644 docs/features/app-pricing.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c3c565..917773d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Submit in-app purchases and subscriptions with an app version** — `asc versions submit --version-id --with-products` adds every in-app purchase and subscription that is `READY_TO_SUBMIT` with a submittable version (and its subscription group's version) to the app version's review submission and submits them together — the way first-time products must go to review — using only the public API and your API key, no iris web session. `--dry-run` lists what would be submitted and submits nothing. REST: `POST /api/v1/versions/:id/submit?with-products=true&dry-run=true` (the route `submitForReview` links pointed at now exists). See `docs/features/submit-with-products.md`. - **Product versions** — `asc iap versions list --iap-id`, `asc subscriptions versions list --subscription-id` and `asc subscription-groups versions list --group-id` show each product's review versions and state; a submittable version offers `addToSubmission`. IAPs, subscriptions and groups gain a `listVersions` affordance. REST: `GET /api/v1/{iap,subscriptions,subscription-groups}/:id/versions`. - **Build a review submission step by step** — `asc review-submissions create --app-id [--platform]` (opens or reuses the app's draft), `items add --submission-id` with one of `--version-id`, `--iap-version-id`, `--subscription-version-id`, `--subscription-group-version-id`, `items remove --item-id`, and `submit --submission-id`. REST: `POST /api/v1/apps/:appId/review-submissions`, `POST /api/v1/review-submissions/:id/items`, `DELETE /api/v1/review-submissions/items/:itemId`, `POST /api/v1/review-submissions/:id/submit`. +- **App pricing** — `asc apps price-points list --app-id [--territory USA]` lists the prices an app can be sold at (every page, ~800 per territory; the `0.0` point makes it free) and `asc apps prices set --app-id --base-territory --price-point-id` sets the app's price, which Apple equalizes worldwide. Fixes the "App is not eligible for submission until pricing has been set" refusal from the CLI. `App` gains a `listPricePoints` affordance. REST: `GET /api/v1/apps/:appId/price-points?territory=`, `POST /api/v1/apps/:appId/prices/set`. See `docs/features/app-pricing.md`. - **`STORAGE` performance metrics** — the new category Apple reports is mapped and usable with `perf-metrics list --metric-type STORAGE`. ### Changed diff --git a/README.md b/README.md index 4f112ebf..07621f59 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Now `asc iap list --app-id ` enriches each IAP with the right submission aff | Category | What you can do | | --- | --- | -| **Apps & Versions** | List apps, create versions, link builds, submit for App Store review — with first-time IAPs and subscriptions in the same submission (`--with-products`) | +| **Apps & Versions** | List apps, set the app's price (or make it free), create versions, link builds, submit for App Store review — with first-time IAPs and subscriptions in the same submission (`--with-products`) | | **Builds** | Archive Xcode projects, export IPA/PKG, upload to App Store Connect, distribute to TestFlight, update beta notes | | **Metadata** | Update What's New, description, and keywords per locale | | **App Info** | Set per-locale name, subtitle, privacy policy; manage categories and age rating | @@ -163,6 +163,8 @@ asc versions list --app-id asc versions create --app-id --version --platform ios asc versions set-build --version-id --build-id asc versions check-readiness --version-id +asc apps price-points list --app-id [--territory USA] +asc apps prices set --app-id --base-territory USA --price-point-id # 0.0 point = free asc versions submit --version-id [--with-products] [--dry-run] asc version-review-detail get --version-id asc version-review-detail update --version-id --contact-first-name Jane --contact-email dev@example.com diff --git a/Sources/ASCCommand/Commands/Apps/AppsCommand.swift b/Sources/ASCCommand/Commands/Apps/AppsCommand.swift index 49cff22e..bcf48238 100644 --- a/Sources/ASCCommand/Commands/Apps/AppsCommand.swift +++ b/Sources/ASCCommand/Commands/Apps/AppsCommand.swift @@ -4,6 +4,6 @@ struct AppsCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "apps", abstract: "Manage apps", - subcommands: [AppsList.self, AppsUpdate.self] + subcommands: [AppsList.self, AppsUpdate.self, AppsPricePointsCommand.self, AppsPricesCommand.self] ) } diff --git a/Sources/ASCCommand/Commands/Apps/AppsPricePointsCommand.swift b/Sources/ASCCommand/Commands/Apps/AppsPricePointsCommand.swift new file mode 100644 index 00000000..772f247c --- /dev/null +++ b/Sources/ASCCommand/Commands/Apps/AppsPricePointsCommand.swift @@ -0,0 +1,9 @@ +import ArgumentParser + +struct AppsPricePointsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "price-points", + abstract: "List the prices an app can be sold at in a territory", + subcommands: [AppsPricePointsList.self] + ) +} diff --git a/Sources/ASCCommand/Commands/Apps/AppsPricePointsList.swift b/Sources/ASCCommand/Commands/Apps/AppsPricePointsList.swift new file mode 100644 index 00000000..3729bad5 --- /dev/null +++ b/Sources/ASCCommand/Commands/Apps/AppsPricePointsList.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import Domain + +struct AppsPricePointsList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List the prices an app can be sold at in a territory (the 0 price makes it free)" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "App ID") + var appId: String + + @Option(name: .long, help: "Territory code (default: USA)") + var territory: String = "USA" + + func run() async throws { + let repo = try ClientProvider.makePricingRepository() + print(try await execute(repo: repo)) + } + + func execute(repo: any PricingRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { + let points = try await repo.listPricePoints(appId: appId, territory: territory) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems(points, affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Apps/AppsPricesCommand.swift b/Sources/ASCCommand/Commands/Apps/AppsPricesCommand.swift new file mode 100644 index 00000000..16888e84 --- /dev/null +++ b/Sources/ASCCommand/Commands/Apps/AppsPricesCommand.swift @@ -0,0 +1,9 @@ +import ArgumentParser + +struct AppsPricesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "prices", + abstract: "Set an app's price", + subcommands: [AppsPricesSet.self] + ) +} diff --git a/Sources/ASCCommand/Commands/Apps/AppsPricesSet.swift b/Sources/ASCCommand/Commands/Apps/AppsPricesSet.swift new file mode 100644 index 00000000..64b44f71 --- /dev/null +++ b/Sources/ASCCommand/Commands/Apps/AppsPricesSet.swift @@ -0,0 +1,31 @@ +import ArgumentParser +import Domain + +struct AppsPricesSet: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set an app's price from a base-territory price point; Apple equalizes the other territories" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "App ID") + var appId: String + + @Option(name: .long, help: "Base territory code, e.g. USA") + var baseTerritory: String + + @Option(name: .long, help: "Price point ID in the base territory (from `asc apps price-points list`)") + var pricePointId: String + + func run() async throws { + let repo = try ClientProvider.makePricingRepository() + print(try await execute(repo: repo)) + } + + func execute(repo: any PricingRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { + let schedule = try await repo.setPriceSchedule(appId: appId, baseTerritory: baseTerritory, pricePointId: pricePointId) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([schedule], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Web/Controllers/AppPricingController.swift b/Sources/ASCCommand/Commands/Web/Controllers/AppPricingController.swift new file mode 100644 index 00000000..2d64d234 --- /dev/null +++ b/Sources/ASCCommand/Commands/Web/Controllers/AppPricingController.swift @@ -0,0 +1,36 @@ +import Foundation +import Domain +import Hummingbird +import HummingbirdWebSocket +import Infrastructure + +/// App pricing (mirrors `IAPPricePointsController` / `IAPPricesController`): +/// `GET /apps/:appId/price-points?territory=USA` and `POST /apps/:appId/prices/set` +/// with body `{"base-territory": "USA", "price-point-id": "…"}` (camelCase keys also accepted). +struct AppPricingController: Sendable { + let repo: any PricingRepository + + func addRoutes(to group: RouterGroup) { + group.get("/apps/:appId/price-points") { request, context -> Response in + guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") } + let territory = request.uri.queryParameters.get("territory").map { String($0) } ?? "USA" + return try restFormat(try await self.repo.listPricePoints(appId: appId, territory: territory)) + } + + group.post("/apps/:appId/prices/set") { request, context -> Response in + guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") } + let body = try await request.body.collect(upTo: 64 * 1024) + let json = (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) ?? [:] + guard let baseTerritory = json["base-territory"] as? String ?? json["baseTerritory"] as? String else { + return jsonError("Missing base-territory", status: .badRequest) + } + guard let pricePointId = json["price-point-id"] as? String ?? json["pricePointId"] as? String else { + return jsonError("Missing price-point-id", status: .badRequest) + } + let schedule = try await self.repo.setPriceSchedule( + appId: appId, baseTerritory: baseTerritory, pricePointId: pricePointId + ) + return try restFormat([schedule]) + } + } +} diff --git a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift index 6b75a5c5..868a6e43 100644 --- a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift +++ b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift @@ -59,6 +59,9 @@ enum RESTRoutes { if let promotedRepo = try? factory.makePromotedPurchaseRepository(authProvider: auth) { PromotedPurchasesController(repo: promotedRepo).addRoutes(to: v1) } + if let pricingRepo = try? factory.makePricingRepository(authProvider: auth) { + AppPricingController(repo: pricingRepo).addRoutes(to: v1) + } if let productVersionRepo = try? factory.makeProductVersionRepository(authProvider: auth) { ProductVersionsController(repo: productVersionRepo).addRoutes(to: v1) if let submissionRepo = try? factory.makeSubmissionRepository(authProvider: auth), diff --git a/Sources/Domain/Apps/App.swift b/Sources/Domain/Apps/App.swift index e3f8ab2e..e087bafa 100644 --- a/Sources/Domain/Apps/App.swift +++ b/Sources/Domain/Apps/App.swift @@ -90,6 +90,8 @@ extension App: AffordanceProviding { Affordance(key: "listAppInfos", command: "app-infos", action: "list", params: ["app-id": id]), Affordance(key: "listReviews", command: "reviews", action: "list", params: ["app-id": id]), Affordance(key: "listExperiments", command: "experiments", action: "list", params: ["app-id": id]), + Affordance(key: "listPricePoints", command: "apps price-points", action: "list", + params: ["app-id": id, "territory": "USA"]), Affordance(key: "updateContentRights", command: "apps", action: "update", params: ["app-id": id]), ] } diff --git a/Sources/Domain/Apps/Pricing/AppPricePoint.swift b/Sources/Domain/Apps/Pricing/AppPricePoint.swift new file mode 100644 index 00000000..1f4d5e73 --- /dev/null +++ b/Sources/Domain/Apps/Pricing/AppPricePoint.swift @@ -0,0 +1,63 @@ +/// One price an app can be sold at in a territory (Apple's fixed price tiers). +/// Setting the app's price means picking one of these as the base-territory price; +/// Apple equalizes the other territories from it. +public struct AppPricePoint: Sendable, Equatable, Identifiable, Codable { + public let id: String + /// Parent app — Apple's response omits it, so Infrastructure injects it. + public let appId: String + public let territory: String? + public let customerPrice: String? + public let proceeds: String? + + public init(id: String, appId: String, territory: String?, customerPrice: String?, proceeds: String?) { + self.id = id + self.appId = appId + self.territory = territory + self.customerPrice = customerPrice + self.proceeds = proceeds + } + + /// The zero price point — choosing it makes the app free. + public var isFree: Bool { customerPrice.flatMap(Double.init) == 0 } +} + +extension AppPricePoint { + enum CodingKeys: String, CodingKey { + case id, appId, territory, customerPrice, proceeds + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + appId = try c.decode(String.self, forKey: .appId) + territory = try c.decodeIfPresent(String.self, forKey: .territory) + customerPrice = try c.decodeIfPresent(String.self, forKey: .customerPrice) + proceeds = try c.decodeIfPresent(String.self, forKey: .proceeds) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(appId, forKey: .appId) + try c.encodeIfPresent(territory, forKey: .territory) + try c.encodeIfPresent(customerPrice, forKey: .customerPrice) + try c.encodeIfPresent(proceeds, forKey: .proceeds) + } +} + +extension AppPricePoint: Presentable { + public static var tableHeaders: [String] { ["ID", "Territory", "Customer Price", "Proceeds"] } + public var tableRow: [String] { [id, territory ?? "", customerPrice ?? "", proceeds ?? ""] } +} + +extension AppPricePoint: AffordanceProviding { + public var structuredAffordances: [Affordance] { + guard let territory else { return [] } + return [ + Affordance(key: "listPricePoints", command: "apps price-points", action: "list", + params: ["app-id": appId, "territory": territory]), + Affordance(key: "setPrice", command: "apps prices", action: "set", + params: ["app-id": appId, "base-territory": territory, "price-point-id": id]), + ] + } +} diff --git a/Sources/Domain/Apps/Pricing/AppPriceSchedule.swift b/Sources/Domain/Apps/Pricing/AppPriceSchedule.swift new file mode 100644 index 00000000..bfef5c07 --- /dev/null +++ b/Sources/Domain/Apps/Pricing/AppPriceSchedule.swift @@ -0,0 +1,25 @@ +/// An app's price schedule: the base-territory price Apple equalizes worldwide. +public struct AppPriceSchedule: Sendable, Equatable, Identifiable, Codable { + public let id: String + /// Parent app — injected by Infrastructure. + public let appId: String + public let baseTerritory: String + + public init(id: String, appId: String, baseTerritory: String) { + self.id = id + self.appId = appId + self.baseTerritory = baseTerritory + } +} + +extension AppPriceSchedule: Presentable { + public static var tableHeaders: [String] { ["ID", "App ID", "Base Territory"] } + public var tableRow: [String] { [id, appId, baseTerritory] } +} + +extension AppPriceSchedule: AffordanceProviding { + public var structuredAffordances: [Affordance] { + [Affordance(key: "listPricePoints", command: "apps price-points", action: "list", + params: ["app-id": appId, "territory": baseTerritory])] + } +} diff --git a/Sources/Domain/Apps/Pricing/AppPricing+RESTRoutes.swift b/Sources/Domain/Apps/Pricing/AppPricing+RESTRoutes.swift new file mode 100644 index 00000000..8ec3aa80 --- /dev/null +++ b/Sources/Domain/Apps/Pricing/AppPricing+RESTRoutes.swift @@ -0,0 +1,7 @@ +/// REST route registrations for app pricing (mirrors `iap price-points` / `iap prices`). +extension RESTPathResolver { + static let _appPricingRoutes: Void = { + registerRoute(command: "apps price-points", parentParam: "app-id", parentSegment: "apps", segment: "price-points") + registerRoute(command: "apps prices", parentParam: "app-id", parentSegment: "apps", segment: "prices") + }() +} diff --git a/Sources/Domain/Apps/Pricing/PricingRepository.swift b/Sources/Domain/Apps/Pricing/PricingRepository.swift index 7f1535cb..5b93e66f 100644 --- a/Sources/Domain/Apps/Pricing/PricingRepository.swift +++ b/Sources/Domain/Apps/Pricing/PricingRepository.swift @@ -3,4 +3,8 @@ import Mockable @Mockable public protocol PricingRepository: Sendable { func hasPricing(appId: String) async throws -> Bool + /// The prices the app can be sold at in `territory`. + func listPricePoints(appId: String, territory: String) async throws -> [AppPricePoint] + /// Sets the app's price: `pricePointId` in `baseTerritory`, equalized worldwide by Apple. + func setPriceSchedule(appId: String, baseTerritory: String, pricePointId: String) async throws -> AppPriceSchedule } diff --git a/Sources/Domain/Shared/RESTPathResolver.swift b/Sources/Domain/Shared/RESTPathResolver.swift index 18aec972..96fdaa4b 100644 --- a/Sources/Domain/Shared/RESTPathResolver.swift +++ b/Sources/Domain/Shared/RESTPathResolver.swift @@ -167,6 +167,7 @@ public final class RESTPathResolver: @unchecked Sendable { _ = _appShotsRoutes _ = _submissionRoutes _ = _productVersionRoutes + _ = _appPricingRoutes _ = _resolutionCenterRoutes _ = _experimentRoutes diff --git a/Sources/Infrastructure/Apps/Pricing/SDKPricingRepository.swift b/Sources/Infrastructure/Apps/Pricing/SDKPricingRepository.swift index 4af86a80..3c1ac3ab 100644 --- a/Sources/Infrastructure/Apps/Pricing/SDKPricingRepository.swift +++ b/Sources/Infrastructure/Apps/Pricing/SDKPricingRepository.swift @@ -17,4 +17,44 @@ public struct SDKPricingRepository: PricingRepository, @unchecked Sendable { return false } } + + public func listPricePoints(appId: String, territory: String) async throws -> [Domain.AppPricePoint] { + let pages = try await client.requestAllPages( + APIEndpoint.v1.apps.id(appId).appPricePoints.get(parameters: .init( + filterTerritory: [territory], + fieldsAppPricePoints: [.customerPrice, .proceeds], + limit: 200 + )), + nextCursor: { $0.meta?.paging.nextCursor } + ) + // Filtered to one territory, so every point belongs to it. + return pages.flatMap(\.data).map { + Domain.AppPricePoint(id: $0.id, appId: appId, territory: territory, + customerPrice: $0.attributes?.customerPrice, proceeds: $0.attributes?.proceeds) + } + } + + public func setPriceSchedule(appId: String, baseTerritory: String, pricePointId: String) async throws -> Domain.AppPriceSchedule { + // One inline manual price, correlated by a `${...}` local id (same shape as IAP price schedules). + let localId = "${local-app-price-1}" + let body = AppPriceScheduleCreateRequest( + data: .init( + type: .appPriceSchedules, + relationships: .init( + app: .init(data: .init(type: .apps, id: appId)), + baseTerritory: .init(data: .init(type: .territories, id: baseTerritory)), + manualPrices: .init(data: [.init(type: .appPrices, id: localId)]) + ) + ), + included: [ + .appPriceV2InlineCreate(.init( + type: .appPrices, + id: localId, + relationships: .init(appPricePoint: .init(data: .init(type: .appPricePoints, id: pricePointId))) + )), + ] + ) + let response = try await client.request(APIEndpoint.v1.appPriceSchedules.post(body)) + return Domain.AppPriceSchedule(id: response.data.id, appId: appId, baseTerritory: baseTerritory) + } } diff --git a/Tests/ASCCommandTests/Commands/Apps/AppPricingCommandsTests.swift b/Tests/ASCCommandTests/Commands/Apps/AppPricingCommandsTests.swift new file mode 100644 index 00000000..2f221fed --- /dev/null +++ b/Tests/ASCCommandTests/Commands/Apps/AppPricingCommandsTests.swift @@ -0,0 +1,69 @@ +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +@Suite +struct AppPricingCommandsTests { + + @Test func `listed price points show the price and the command to set it`() async throws { + let mockRepo = MockPricingRepository() + given(mockRepo).listPricePoints(appId: .value("app-1"), territory: .value("USA")).willReturn([ + AppPricePoint(id: "pp-1", appId: "app-1", territory: "USA", customerPrice: "4.99", proceeds: "4.24"), + ]) + + let cmd = try AppsPricePointsList.parse(["--app-id", "app-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "listPricePoints" : "asc apps price-points list --app-id app-1 --territory USA", + "setPrice" : "asc apps prices set --app-id app-1 --base-territory USA --price-point-id pp-1" + }, + "appId" : "app-1", + "customerPrice" : "4.99", + "id" : "pp-1", + "proceeds" : "4.24", + "territory" : "USA" + } + ] + } + """) + } + + @Test func `price points can be listed for another territory`() async throws { + let mockRepo = MockPricingRepository() + given(mockRepo).listPricePoints(appId: .any, territory: .any).willReturn([]) + + _ = try await AppsPricePointsList.parse(["--app-id", "app-1", "--territory", "JPN"]).execute(repo: mockRepo) + + verify(mockRepo).listPricePoints(appId: .value("app-1"), territory: .value("JPN")).called(1) + } + + @Test func `setting the price shows the new schedule`() async throws { + let mockRepo = MockPricingRepository() + given(mockRepo).setPriceSchedule(appId: .value("app-1"), baseTerritory: .value("USA"), pricePointId: .value("pp-1")) + .willReturn(AppPriceSchedule(id: "sched-1", appId: "app-1", baseTerritory: "USA")) + + let cmd = try AppsPricesSet.parse(["--app-id", "app-1", "--base-territory", "USA", "--price-point-id", "pp-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "listPricePoints" : "asc apps price-points list --app-id app-1 --territory USA" + }, + "appId" : "app-1", + "baseTerritory" : "USA", + "id" : "sched-1" + } + ] + } + """) + } +} diff --git a/Tests/ASCCommandTests/Commands/Apps/AppsListTests.swift b/Tests/ASCCommandTests/Commands/Apps/AppsListTests.swift index ac8f7515..c7ed23dd 100644 --- a/Tests/ASCCommandTests/Commands/Apps/AppsListTests.swift +++ b/Tests/ASCCommandTests/Commands/Apps/AppsListTests.swift @@ -25,6 +25,7 @@ struct AppsListTests { "createVersion" : "asc versions create --app-id app-1", "listAppInfos" : "asc app-infos list --app-id app-1", "listExperiments" : "asc experiments list --app-id app-1", + "listPricePoints" : "asc apps price-points list --app-id app-1 --territory USA", "listReviews" : "asc reviews list --app-id app-1", "listVersions" : "asc versions list --app-id app-1", "updateContentRights" : "asc apps update --app-id app-1" @@ -58,6 +59,7 @@ struct AppsListTests { "createVersion" : "asc versions create --app-id app-1", "listAppInfos" : "asc app-infos list --app-id app-1", "listExperiments" : "asc experiments list --app-id app-1", + "listPricePoints" : "asc apps price-points list --app-id app-1 --territory USA", "listReviews" : "asc reviews list --app-id app-1", "listVersions" : "asc versions list --app-id app-1", "updateContentRights" : "asc apps update --app-id app-1" diff --git a/Tests/ASCCommandTests/Commands/Apps/AppsUpdateTests.swift b/Tests/ASCCommandTests/Commands/Apps/AppsUpdateTests.swift index 93d8755c..c9782d92 100644 --- a/Tests/ASCCommandTests/Commands/Apps/AppsUpdateTests.swift +++ b/Tests/ASCCommandTests/Commands/Apps/AppsUpdateTests.swift @@ -27,6 +27,7 @@ struct AppsUpdateTests { "createVersion" : "asc versions create --app-id app-9", "listAppInfos" : "asc app-infos list --app-id app-9", "listExperiments" : "asc experiments list --app-id app-9", + "listPricePoints" : "asc apps price-points list --app-id app-9 --territory USA", "listReviews" : "asc reviews list --app-id app-9", "listVersions" : "asc versions list --app-id app-9", "updateContentRights" : "asc apps update --app-id app-9" diff --git a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift index 32c1776b..4719a90d 100644 --- a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift +++ b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift @@ -1229,4 +1229,20 @@ struct RESTRoutesTests { #expect(output.contains("/api/v1/versions/v-1")) #expect(output.contains("/api/v1/iap/iap-1/versions")) } + + // MARK: - App pricing + + @Test func `app price points link to setting the app's price over REST`() async throws { + let mockRepo = MockPricingRepository() + given(mockRepo).listPricePoints(appId: .any, territory: .any).willReturn([ + AppPricePoint(id: "pp-1", appId: "app-1", territory: "USA", customerPrice: "0.0", proceeds: "0.0"), + ]) + + let output = try await AppsPricePointsList.parse(["--app-id", "app-1"]) + .execute(repo: mockRepo, affordanceMode: .rest).replacingOccurrences(of: "\\/", with: "/") + + #expect(output.contains("\"_links\"")) + #expect(output.contains("/api/v1/apps/app-1/prices/set")) + #expect(output.contains("/api/v1/apps/app-1/price-points")) + } } diff --git a/Tests/ASCCommandTests/OutputFormatterTests.swift b/Tests/ASCCommandTests/OutputFormatterTests.swift index add5a52f..5de8cf8c 100644 --- a/Tests/ASCCommandTests/OutputFormatterTests.swift +++ b/Tests/ASCCommandTests/OutputFormatterTests.swift @@ -83,6 +83,7 @@ struct OutputFormatterTests { "createVersion" : "asc versions create --app-id 1", "listAppInfos" : "asc app-infos list --app-id 1", "listExperiments" : "asc experiments list --app-id 1", + "listPricePoints" : "asc apps price-points list --app-id 1 --territory USA", "listReviews" : "asc reviews list --app-id 1", "listVersions" : "asc versions list --app-id 1", "updateContentRights" : "asc apps update --app-id 1" @@ -113,6 +114,7 @@ struct OutputFormatterTests { "createVersion" : "asc versions create --app-id app-1", "listAppInfos" : "asc app-infos list --app-id app-1", "listExperiments" : "asc experiments list --app-id app-1", + "listPricePoints" : "asc apps price-points list --app-id app-1 --territory USA", "listReviews" : "asc reviews list --app-id app-1", "listVersions" : "asc versions list --app-id app-1", "updateContentRights" : "asc apps update --app-id app-1" diff --git a/Tests/DomainTests/Apps/Pricing/AppPricingTests.swift b/Tests/DomainTests/Apps/Pricing/AppPricingTests.swift new file mode 100644 index 00000000..242066a3 --- /dev/null +++ b/Tests/DomainTests/Apps/Pricing/AppPricingTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import Domain + +@Suite +struct AppPricingTests { + + // MARK: - AppPricePoint + + @Test func `a price point in a territory offers setting the app's price to it`() { + let point = AppPricePoint(id: "pp-1", appId: "app-1", territory: "USA", customerPrice: "4.99", proceeds: "4.24") + #expect(point.affordances["setPrice"] + == "asc apps prices set --app-id app-1 --base-territory USA --price-point-id pp-1") + #expect(point.affordances["listPricePoints"] == "asc apps price-points list --app-id app-1 --territory USA") + #expect(point.apiLinks["setPrice"]?.href == "/api/v1/apps/app-1/prices/set") + #expect(point.apiLinks["setPrice"]?.method == "POST") + #expect(point.apiLinks["listPricePoints"]?.href == "/api/v1/apps/app-1/price-points") + } + + @Test func `a price point without a known territory cannot be set`() { + let point = AppPricePoint(id: "pp-1", appId: "app-1", territory: nil, customerPrice: "4.99", proceeds: nil) + #expect(point.affordances["setPrice"] == nil) + } + + @Test func `the zero price point makes the app free`() { + #expect(AppPricePoint(id: "pp-0", appId: "app-1", territory: "USA", customerPrice: "0.0", proceeds: "0.0").isFree) + #expect(AppPricePoint(id: "pp-0", appId: "app-1", territory: "USA", customerPrice: "0", proceeds: "0").isFree) + #expect(!AppPricePoint(id: "pp-1", appId: "app-1", territory: "USA", customerPrice: "0.99", proceeds: "0.84").isFree) + } + + // MARK: - AppPriceSchedule + + @Test func `a new price schedule points back at the price points of its base territory`() { + let schedule = AppPriceSchedule(id: "sched-1", appId: "app-1", baseTerritory: "USA") + #expect(schedule.affordances["listPricePoints"] == "asc apps price-points list --app-id app-1 --territory USA") + } + + // MARK: - App + + @Test func `an app points at its price points`() { + let app = App(id: "app-1", name: "Unveil", bundleId: "com.onegai.unveil") + #expect(app.affordances["listPricePoints"] == "asc apps price-points list --app-id app-1 --territory USA") + #expect(app.apiLinks["listPricePoints"]?.href == "/api/v1/apps/app-1/price-points") + } +} diff --git a/Tests/InfrastructureTests/Apps/Pricing/SDKPricingRepositoryTests.swift b/Tests/InfrastructureTests/Apps/Pricing/SDKPricingRepositoryTests.swift index 0d25aa98..8ada941a 100644 --- a/Tests/InfrastructureTests/Apps/Pricing/SDKPricingRepositoryTests.swift +++ b/Tests/InfrastructureTests/Apps/Pricing/SDKPricingRepositoryTests.swift @@ -39,4 +39,44 @@ private final class ThrowingStubAPIClient: APIClient, @unchecked Sendable { func request(_ endpoint: Request) async throws { throw URLError(.badServerResponse) } + + // MARK: - Setting the app's price + + @Test func `price points for a territory carry the app, territory and price, across every page`() async throws { + func page(_ range: Range, nextCursor: String?) -> AppPricePointsV3Response { + AppPricePointsV3Response( + data: range.map { AppPricePointV3(type: .appPricePoints, id: "pp-\($0)", + attributes: .init(customerPrice: "\($0).99", proceeds: "\($0).50")) }, + links: .init(this: ""), + meta: .init(paging: .init(total: 250, limit: 200, nextCursor: nextCursor)) + ) + } + let stub = StubAPIClient() + stub.willReturnPages([page(0..<200, nextCursor: "page-2"), page(200..<250, nextCursor: nil)]) + + let repo = SDKPricingRepository(client: stub) + let points = try await repo.listPricePoints(appId: "app-1", territory: "USA") + + #expect(points.count == 250) + #expect(points.last == Domain.AppPricePoint(id: "pp-249", appId: "app-1", territory: "USA", + customerPrice: "249.99", proceeds: "249.50")) + let query = Dictionary(uniqueKeysWithValues: (stub.requests.first?.query ?? []).map { ($0.0, $0.1 ?? "") }) + #expect(stub.requests.first?.path == "/v1/apps/app-1/appPricePoints") + #expect(query["filter[territory]"] == "USA") + } + + @Test func `setting the price creates a schedule with the price point as the base-territory price`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppPriceScheduleResponse( + data: .init(type: .appPriceSchedules, id: "sched-1"), links: .init(this: "") + )) + + let repo = SDKPricingRepository(client: stub) + let schedule = try await repo.setPriceSchedule(appId: "app-1", baseTerritory: "USA", pricePointId: "pp-9") + + #expect(schedule == Domain.AppPriceSchedule(id: "sched-1", appId: "app-1", baseTerritory: "USA")) + let post = stub.requests.first { $0.method == "POST" } + #expect(post?.path == "/v1/appPriceSchedules") + #expect(post?.body == #"{"data":{"relationships":{"app":{"data":{"id":"app-1","type":"apps"}},"baseTerritory":{"data":{"id":"USA","type":"territories"}},"manualPrices":{"data":[{"id":"${local-app-price-1}","type":"appPrices"}]}},"type":"appPriceSchedules"},"included":[{"id":"${local-app-price-1}","relationships":{"appPricePoint":{"data":{"id":"pp-9","type":"appPricePoints"}}},"type":"appPrices"}]}"#) + } } diff --git a/docs/features/app-pricing.md b/docs/features/app-pricing.md new file mode 100644 index 00000000..4f631a14 --- /dev/null +++ b/docs/features/app-pricing.md @@ -0,0 +1,221 @@ +# App Pricing + +Set what an app costs — including making it free — from the CLI. A submission is refused with "App is not eligible for submission until pricing has been set" until an app has a price schedule, so this is part of every first release. + +Apple prices apps on fixed **price points** per territory. You pick one point in a base territory; Apple equalizes the price in every other territory. The zero point makes the app free (the usual choice for apps that earn through in-app purchases or subscriptions). + +Mirrors the IAP commands (`asc iap price-points list`, `asc iap prices set`). + +--- + +## CLI Usage + +### `asc apps price-points list` + +``` +asc apps price-points list --app-id [--territory USA] +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--app-id` | Yes | App ID | +| `--territory` | No | Territory code; default `USA`. Every page is fetched (~800 points per territory) | + +```bash +asc apps price-points list --app-id 6792459661 --output table | head -4 +``` + +``` +ID Territory Customer Price Proceeds +--------------------------- --------- -------------- -------- +eyJzIjoiNjc5MjQ1OTY2MSIs… USA 0.0 0.0 +``` + +```json +{ + "data" : [ + { + "affordances" : { + "listPricePoints" : "asc apps price-points list --app-id 6792459661 --territory USA", + "setPrice" : "asc apps prices set --app-id 6792459661 --base-territory USA --price-point-id " + }, + "appId" : "6792459661", + "customerPrice" : "0.0", + "id" : "", + "proceeds" : "0.0", + "territory" : "USA" + } + ] +} +``` + +### `asc apps prices set` + +``` +asc apps prices set --app-id --base-territory --price-point-id +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--app-id` | Yes | App ID | +| `--base-territory` | Yes | Territory the price point belongs to, e.g. `USA` | +| `--price-point-id` | Yes | From `asc apps price-points list` for that territory | + +Creates the app's price schedule (replacing the current one). Output: + +```json +{ + "data" : [ + { + "affordances" : { + "listPricePoints" : "asc apps price-points list --app-id 6792459661 --territory USA" + }, + "appId" : "6792459661", + "baseTerritory" : "USA", + "id" : "" + } + ] +} +``` + +--- + +## REST Endpoints + +| Method | Path | CLI flag → REST | +|--------|------|-----------------| +| `GET` | `/api/v1/apps/{appId}/price-points` | `--territory` → `?territory=` | +| `POST` | `/api/v1/apps/{appId}/prices/set` | body `{"base-territory": "USA", "price-point-id": "…"}` (camelCase `baseTerritory` / `pricePointId` also accepted) | + +```bash +curl 'http://localhost:8420/api/v1/apps/6792459661/price-points?territory=USA' +curl -X POST http://localhost:8420/api/v1/apps/6792459661/prices/set \ + -d '{"base-territory": "USA", "price-point-id": ""}' +``` + +--- + +## Typical Workflow + +```bash +APP_ID=6792459661 + +# Free app +FREE=$(asc apps price-points list --app-id $APP_ID | jq -r '.data[] | select(.customerPrice == "0.0") | .id') +asc apps prices set --app-id $APP_ID --base-territory USA --price-point-id "$FREE" + +# …or a paid app at $4.99 +PAID=$(asc apps price-points list --app-id $APP_ID | jq -r '.data[] | select(.customerPrice == "4.99") | .id') +asc apps prices set --app-id $APP_ID --base-territory USA --price-point-id "$PAID" + +asc versions check-readiness --version-id # pricingCheck now passes +``` + +--- + +## Architecture + +``` +ASCCommand Infrastructure Domain +────────── ────────────── ────── +apps price-points list ─┐ +apps prices set ────────┼─▶ SDKPricingRepository ─────────────────▶ PricingRepository +AppPricingController ───┘ GET /v1/apps/{id}/appPricePoints AppPricePoint (isFree) + POST /v1/appPriceSchedules AppPriceSchedule +``` + +`ASCCommand → Infrastructure → Domain`. `PricingRepository.hasPricing(appId:)` (used by `check-readiness`) lives on the same protocol. + +--- + +## Domain Models + +### `AppPricePoint` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `String` | Price point id | +| `appId` | `String` | Injected from the request | +| `territory` | `String?` | Injected from the `--territory` filter | +| `customerPrice` | `String?` | e.g. `"4.99"`, `"0.0"` | +| `proceeds` | `String?` | Developer proceeds | + +Computed: `isFree` (customer price is zero). Affordances (when `territory` is known): `listPricePoints`, `setPrice`. + +### `AppPriceSchedule` + +`id`, `appId`, `baseTerritory`. Affordance: `listPricePoints` for the base territory. + +### `PricingRepository` + +```swift +@Mockable +public protocol PricingRepository: Sendable { + func hasPricing(appId: String) async throws -> Bool + func listPricePoints(appId: String, territory: String) async throws -> [AppPricePoint] + func setPriceSchedule(appId: String, baseTerritory: String, pricePointId: String) async throws -> AppPriceSchedule +} +``` + +`App` gains a `listPricePoints` affordance (`--territory USA`). + +--- + +## File Map + +``` +Sources/ +├── Domain/Apps/Pricing/ +│ ├── AppPricePoint.swift +│ ├── AppPriceSchedule.swift +│ ├── AppPricing+RESTRoutes.swift # `apps price-points`, `apps prices` routes +│ └── PricingRepository.swift +├── Infrastructure/Apps/Pricing/SDKPricingRepository.swift +└── ASCCommand/Commands/Apps/ + ├── AppsPricePointsCommand.swift, AppsPricePointsList.swift + └── AppsPricesCommand.swift, AppsPricesSet.swift + +Tests/ +├── DomainTests/Apps/Pricing/AppPricingTests.swift +├── InfrastructureTests/Apps/Pricing/SDKPricingRepositoryTests.swift +└── ASCCommandTests/Commands/Apps/AppPricingCommandsTests.swift, Web/RESTRoutesTests.swift +``` + +| Wiring file | Purpose | +|-------------|---------| +| `Sources/ASCCommand/Commands/Apps/AppsCommand.swift` | Registers `price-points` and `prices` under `apps` | +| `Sources/ASCCommand/Commands/Web/Controllers/AppPricingController.swift` | REST routes | +| `Sources/ASCCommand/Commands/Web/RESTRoutes.swift` | Constructs `AppPricingController` | +| `Sources/Domain/Shared/RESTPathResolver.swift` | Touches `_appPricingRoutes` | + +--- + +## API Reference + +| Operation | Endpoint | SDK call | Repository method | +|-----------|----------|----------|-------------------| +| List price points | `GET /v1/apps/{id}/appPricePoints?filter[territory]=USA&limit=200` (all pages) | `APIEndpoint.v1.apps.id(id).appPricePoints.get` | `listPricePoints(appId:territory:)` | +| Set price | `POST /v1/appPriceSchedules` — relationships `app`, `baseTerritory`, `manualPrices` → inline `appPrices` `${local-app-price-1}` with `appPricePoint` | `APIEndpoint.v1.appPriceSchedules.post` | `setPriceSchedule(appId:baseTerritory:pricePointId:)` | +| Has pricing | `GET /v1/apps/{id}/appPriceSchedule` | `APIEndpoint.v1.apps.id(id).appPriceSchedule.get` | `hasPricing(appId:)` | + +--- + +## Testing + +```swift +@Test func `the zero price point makes the app free`() { + #expect(AppPricePoint(id: "pp-0", appId: "app-1", territory: "USA", customerPrice: "0.0", proceeds: "0.0").isFree) +} +``` + +```bash +swift test --filter 'AppPricing|SDKPricingRepository' +``` + +--- + +## Extending + +- **Read the current schedule** (`asc apps prices get`): `GET /v1/apps/{id}/appPriceSchedule?include=baseTerritory,manualPrices`, like `iap-price-schedule get`. +- **Scheduled price changes**: `AppPriceV2InlineCreate` takes `startDate`/`endDate`; add `--start-date` to `prices set`. +- **Free-app shortcut**: `asc apps prices set --app-id --free`, resolving the zero point via `AppPricePoint.isFree`. diff --git a/docs/features/submit-with-products.md b/docs/features/submit-with-products.md index 2abe1403..4dc87b3e 100644 --- a/docs/features/submit-with-products.md +++ b/docs/features/submit-with-products.md @@ -89,7 +89,7 @@ What each common refusal needs, and whether `asc` can fix it: | Reason | Fix | |--------|-----| | `contentRightsDeclaration` required | `asc apps update --app-id --content-rights-declaration DOES_NOT_USE_THIRD_PARTY_CONTENT` (or `USES_THIRD_PARTY_CONTENT`) | -| Pricing not set | App pricing is in the public API (`/v1/appPriceSchedules`) but `asc` has no command yet — set it in App Store Connect (Pricing and Availability) | +| Pricing not set | `asc apps price-points list --app-id ` then `asc apps prices set --app-id --base-territory USA --price-point-id ` (the `0.0` point makes it free) — see [app-pricing.md](app-pricing.md) | | App Privacy data usages not published | **No public API.** App Privacy answers exist only behind the App Store Connect web session (the `/v1/appDataUsages` path in the error isn't reachable with an API key) — publish them in App Store Connect (App Privacy) | | Required screenshot (e.g. `APP_IPAD_PRO_3GEN_129`) missing | `asc screenshot-sets` / `asc screenshots` for that display type | From 09c4a35b92f579f8cfc7ab04798bf37708558d23 Mon Sep 17 00:00:00 2001 From: slamhan Date: Wed, 23 Sep 2026 18:19:08 +0800 Subject: [PATCH 2/2] feat(app-availability): set up where an app is available `asc app-availability create --app-id (--territory X ... | --all-territories) [--available-in-new-territories]` sets up an app's availability in one POST /v2/appAvailabilities with each territory inlined, then reads it back. `app-availability get` on an app that was never set up now returns `data: []` plus a stderr hint instead of a raw 404. AppAvailability moves to structured affordances and Presentable; REST GET/POST /api/v1/apps/:appId/availability. Verified live: Unveil (never set up) -> 175/175 territories, available in new territories; REST GET returns them. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 + .../Availability/AppAvailabilityCommand.swift | 1 + .../Availability/AppAvailabilityCreate.swift | 46 +++++++++++ .../Availability/AppAvailabilityGet.swift | 23 +++--- .../AppAvailabilityController.swift | 40 ++++++++++ .../ASCCommand/Commands/Web/RESTRoutes.swift | 4 + .../Apps/Availability/AppAvailability.swift | 19 ++++- .../AppAvailabilityRepository.swift | 9 ++- Sources/Domain/Shared/RESTPathResolver.swift | 1 + .../SDKAppAvailabilityRepository.swift | 42 +++++++++- .../AppAvailabilityCreateTests.swift | 78 +++++++++++++++++++ .../AppAvailabilityGetTests.swift | 15 ++++ .../Commands/Web/RESTRoutesTests.swift | 15 ++++ .../Availability/AppAvailabilityTests.swift | 17 ++++ .../SDKAppAvailabilityRepositoryTests.swift | 68 ++++++++++++---- .../features/iap-subscription-availability.md | 19 +++++ 16 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityCreate.swift create mode 100644 Sources/ASCCommand/Commands/Web/Controllers/AppAvailabilityController.swift create mode 100644 Tests/ASCCommandTests/Commands/Apps/Availability/AppAvailabilityCreateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 917773d8..7ae2651b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Product versions** — `asc iap versions list --iap-id`, `asc subscriptions versions list --subscription-id` and `asc subscription-groups versions list --group-id` show each product's review versions and state; a submittable version offers `addToSubmission`. IAPs, subscriptions and groups gain a `listVersions` affordance. REST: `GET /api/v1/{iap,subscriptions,subscription-groups}/:id/versions`. - **Build a review submission step by step** — `asc review-submissions create --app-id [--platform]` (opens or reuses the app's draft), `items add --submission-id` with one of `--version-id`, `--iap-version-id`, `--subscription-version-id`, `--subscription-group-version-id`, `items remove --item-id`, and `submit --submission-id`. REST: `POST /api/v1/apps/:appId/review-submissions`, `POST /api/v1/review-submissions/:id/items`, `DELETE /api/v1/review-submissions/items/:itemId`, `POST /api/v1/review-submissions/:id/submit`. - **App pricing** — `asc apps price-points list --app-id [--territory USA]` lists the prices an app can be sold at (every page, ~800 per territory; the `0.0` point makes it free) and `asc apps prices set --app-id --base-territory --price-point-id` sets the app's price, which Apple equalizes worldwide. Fixes the "App is not eligible for submission until pricing has been set" refusal from the CLI. `App` gains a `listPricePoints` affordance. REST: `GET /api/v1/apps/:appId/price-points?territory=`, `POST /api/v1/apps/:appId/prices/set`. See `docs/features/app-pricing.md`. +- **Set up app availability** — `asc app-availability create --app-id (--territory X … | --all-territories) [--available-in-new-territories]` sets where an app is sold (App Store Connect's "Set Up Availability"), in one `POST /v2/appAvailabilities`. REST: `POST /api/v1/apps/:appId/availability`, plus `GET` for the existing read. - **`STORAGE` performance metrics** — the new category Apple reports is mapped and usable with `perf-metrics list --metric-type STORAGE`. ### Changed @@ -19,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Refused submissions explain why** — when Apple refuses to add an item to, or submit, a review submission, the error lists the specific reasons from Apple's `associatedErrors` (missing device screenshots, content rights declaration, App Privacy answers, pricing) instead of only "please check associated errors". ### Fixed +- **`app-availability get` on an app that was never set up** — printed a raw 404; it now returns `{"data":[]}` and a hint with the `create` command (`getAppAvailability` returns `nil`). - **`review-submissions items list` shows what each item points at** — items never asked Apple for their relationships (`include=`), so every item showed no linked type or id, even app versions. They now show `APP_STORE_VERSION`, the new product version types and the others, with the `getVersion` affordance for app versions. --- diff --git a/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityCommand.swift b/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityCommand.swift index ed536f74..38f07a14 100644 --- a/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityCommand.swift +++ b/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityCommand.swift @@ -6,6 +6,7 @@ struct AppAvailabilityCommand: AsyncParsableCommand { abstract: "Manage app territory availability", subcommands: [ AppAvailabilityGet.self, + AppAvailabilityCreate.self, ], defaultSubcommand: AppAvailabilityGet.self ) diff --git a/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityCreate.swift b/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityCreate.swift new file mode 100644 index 00000000..bba65520 --- /dev/null +++ b/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityCreate.swift @@ -0,0 +1,46 @@ +import ArgumentParser +import Domain + +struct AppAvailabilityCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Set up where an app is available (App Store Connect's \"Set Up Availability\")" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "App ID") + var appId: String + + @Option(name: .long, help: "Territory to make the app available in (e.g. USA). Repeat for several.") + var territory: [String] = [] + + @Flag(name: .long, help: "Make the app available in every territory Apple sells in (`asc territories list`)") + var allTerritories: Bool = false + + @Flag(name: .long, help: "Automatically make the app available in territories Apple adds later") + var availableInNewTerritories: Bool = false + + func run() async throws { + print(try await execute( + repo: try ClientProvider.makeAppAvailabilityRepository(), + territoryRepo: try ClientProvider.makeTerritoryRepository() + )) + } + + func execute( + repo: any AppAvailabilityRepository, + territoryRepo: any TerritoryRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + guard allTerritories != !territory.isEmpty else { + throw ValidationError("Pass either --territory (one or more) or --all-territories") + } + let territoryIds = allTerritories ? try await territoryRepo.listTerritories().map(\.id) : territory + let availability = try await repo.createAppAvailability( + appId: appId, isAvailableInNewTerritories: availableInNewTerritories, territoryIds: territoryIds + ) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([availability], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityGet.swift b/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityGet.swift index 5c99225d..1e616ba2 100644 --- a/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityGet.swift +++ b/Sources/ASCCommand/Commands/Apps/Availability/AppAvailabilityGet.swift @@ -1,3 +1,4 @@ +import Foundation import ArgumentParser import Domain @@ -14,20 +15,20 @@ struct AppAvailabilityGet: AsyncParsableCommand { func run() async throws { let repo = try ClientProvider.makeAppAvailabilityRepository() - print(try await execute(repo: repo)) + let output = try await execute(repo: repo) + print(output) + if output.contains("\"data\" : [\n\n ]") || output.contains("\"data\":[]") { + FileHandle.standardError.write(Data(( + "App availability isn't set up. Set it up with: " + + "asc app-availability create --app-id \(appId) --all-territories --available-in-new-territories\n" + ).utf8)) + } } - func execute(repo: any AppAvailabilityRepository) async throws -> String { + func execute(repo: any AppAvailabilityRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { let availability = try await repo.getAppAvailability(appId: appId) let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) - return try formatter.formatAgentItems( - [availability], - headers: ["ID", "App ID", "Available in New Territories", "Territories"], - rowMapper: { - let available = $0.territories.filter(\.isAvailable).count - let total = $0.territories.count - return [$0.id, $0.appId, String($0.isAvailableInNewTerritories), "\(available)/\(total) available"] - } - ) + // nil → empty data array: availability was never set up (mirrors `iap-availability get`). + return try formatter.formatAgentItems(availability.map { [$0] } ?? [], affordanceMode: affordanceMode) } } diff --git a/Sources/ASCCommand/Commands/Web/Controllers/AppAvailabilityController.swift b/Sources/ASCCommand/Commands/Web/Controllers/AppAvailabilityController.swift new file mode 100644 index 00000000..3d5ac0a6 --- /dev/null +++ b/Sources/ASCCommand/Commands/Web/Controllers/AppAvailabilityController.swift @@ -0,0 +1,40 @@ +import Foundation +import Domain +import Hummingbird +import HummingbirdWebSocket +import Infrastructure + +/// `GET /apps/:appId/availability` — the app's availability (`data: []` when never set up). +/// `POST /apps/:appId/availability` — set it up. Body keys match the CLI flags: +/// `{"territory": ["USA", "JPN"]}` or `{"all-territories": true}`, plus optional +/// `"available-in-new-territories": true`. +struct AppAvailabilityController: Sendable { + let repo: any AppAvailabilityRepository + let territoryRepo: any TerritoryRepository + + func addRoutes(to group: RouterGroup) { + group.get("/apps/:appId/availability") { _, context -> Response in + guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") } + let availability = try await self.repo.getAppAvailability(appId: appId) + return try restFormat(availability.map { [$0] } ?? []) + } + + group.post("/apps/:appId/availability") { request, context -> Response in + guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") } + let body = try await request.body.collect(upTo: 64 * 1024) + let json = (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) ?? [:] + let territories = json["territory"] as? [String] ?? [] + let allTerritories = json["all-territories"] as? Bool ?? false + guard allTerritories != !territories.isEmpty else { + return jsonError("Body needs either territory: [...] or all-territories: true", status: .badRequest) + } + let territoryIds = allTerritories ? try await self.territoryRepo.listTerritories().map(\.id) : territories + let availability = try await self.repo.createAppAvailability( + appId: appId, + isAvailableInNewTerritories: json["available-in-new-territories"] as? Bool ?? false, + territoryIds: territoryIds + ) + return try restFormat([availability]) + } + } +} diff --git a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift index 868a6e43..2aec586e 100644 --- a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift +++ b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift @@ -59,6 +59,10 @@ enum RESTRoutes { if let promotedRepo = try? factory.makePromotedPurchaseRepository(authProvider: auth) { PromotedPurchasesController(repo: promotedRepo).addRoutes(to: v1) } + if let availabilityRepo = try? factory.makeAppAvailabilityRepository(authProvider: auth), + let territoryRepo = try? factory.makeTerritoryRepository(authProvider: auth) { + AppAvailabilityController(repo: availabilityRepo, territoryRepo: territoryRepo).addRoutes(to: v1) + } if let pricingRepo = try? factory.makePricingRepository(authProvider: auth) { AppPricingController(repo: pricingRepo).addRoutes(to: v1) } diff --git a/Sources/Domain/Apps/Availability/AppAvailability.swift b/Sources/Domain/Apps/Availability/AppAvailability.swift index 5b192381..1b08fb5f 100644 --- a/Sources/Domain/Apps/Availability/AppAvailability.swift +++ b/Sources/Domain/Apps/Availability/AppAvailability.swift @@ -19,10 +19,23 @@ public struct AppAvailability: Sendable, Equatable, Identifiable, Codable { } extension AppAvailability: AffordanceProviding { - public var affordances: [String: String] { + public var structuredAffordances: [Affordance] { [ - "getAvailability": "asc app-availability get --app-id \(appId)", - "listTerritories": "asc territories list", + Affordance(key: "getAvailability", command: "app-availability", action: "get", params: ["app-id": appId]), + Affordance(key: "listTerritories", command: "territories", action: "list"), ] } } + +extension AppAvailability: Presentable { + public static var tableHeaders: [String] { ["ID", "App ID", "Available in New Territories", "Territories"] } + public var tableRow: [String] { + [id, appId, String(isAvailableInNewTerritories), "\(territories.filter(\.isAvailable).count)/\(territories.count) available"] + } +} + +extension RESTPathResolver { + static let _appAvailabilityRoutes: Void = { + registerRoute(command: "app-availability", parentParam: "app-id", parentSegment: "apps", segment: "availability") + }() +} diff --git a/Sources/Domain/Apps/Availability/AppAvailabilityRepository.swift b/Sources/Domain/Apps/Availability/AppAvailabilityRepository.swift index 4b12196e..dec72c0e 100644 --- a/Sources/Domain/Apps/Availability/AppAvailabilityRepository.swift +++ b/Sources/Domain/Apps/Availability/AppAvailabilityRepository.swift @@ -2,5 +2,12 @@ import Mockable @Mockable public protocol AppAvailabilityRepository: Sendable { - func getAppAvailability(appId: String) async throws -> AppAvailability + /// `nil` when the app's availability hasn't been set up yet. + func getAppAvailability(appId: String) async throws -> AppAvailability? + /// Sets up the app's availability: available in `territoryIds`. + func createAppAvailability( + appId: String, + isAvailableInNewTerritories: Bool, + territoryIds: [String] + ) async throws -> AppAvailability } diff --git a/Sources/Domain/Shared/RESTPathResolver.swift b/Sources/Domain/Shared/RESTPathResolver.swift index 96fdaa4b..d8b43062 100644 --- a/Sources/Domain/Shared/RESTPathResolver.swift +++ b/Sources/Domain/Shared/RESTPathResolver.swift @@ -168,6 +168,7 @@ public final class RESTPathResolver: @unchecked Sendable { _ = _submissionRoutes _ = _productVersionRoutes _ = _appPricingRoutes + _ = _appAvailabilityRoutes _ = _resolutionCenterRoutes _ = _experimentRoutes diff --git a/Sources/Infrastructure/Apps/Availability/SDKAppAvailabilityRepository.swift b/Sources/Infrastructure/Apps/Availability/SDKAppAvailabilityRepository.swift index 81ddbe08..9ca7bed1 100644 --- a/Sources/Infrastructure/Apps/Availability/SDKAppAvailabilityRepository.swift +++ b/Sources/Infrastructure/Apps/Availability/SDKAppAvailabilityRepository.swift @@ -17,11 +17,16 @@ public struct SDKAppAvailabilityRepository: AppAvailabilityRepository, @unchecke /// The territory code (e.g. `USA`) only appears via the territory relationship — /// `TerritoryAvailability.id` itself is an opaque base64 blob — so include the /// relationship and read its `data.id`. - public func getAppAvailability(appId: String) async throws -> Domain.AppAvailability { + public func getAppAvailability(appId: String) async throws -> Domain.AppAvailability? { let parentRequest = APIEndpoint.v1.apps.id(appId).appAvailabilityV2.get(parameters: .init( fieldsAppAvailabilities: [.availableInNewTerritories] )) - let parent = try await client.request(parentRequest) + let parent: AppAvailabilityV2Response + do { + parent = try await client.request(parentRequest) + } catch APIProvider.Error.requestFailure(404, _, _) { + return nil // never set up (App Store Connect's "Set Up Availability" state) + } let availabilityId = parent.data.id let territoriesRequest = APIEndpoint.v2.appAvailabilities.id(availabilityId).territoryAvailabilities.get(parameters: .init( @@ -40,6 +45,39 @@ public struct SDKAppAvailabilityRepository: AppAvailabilityRepository, @unchecke ) } + public func createAppAvailability( + appId: String, + isAvailableInNewTerritories: Bool, + territoryIds: [String] + ) async throws -> Domain.AppAvailability { + // Each territory is an inline-created territoryAvailability, correlated by a `${...}` local id. + let localIds = territoryIds.map { "${ta-\($0)}" } + let body = AppAvailabilityV2CreateRequest( + data: .init( + type: .appAvailabilities, + attributes: .init(isAvailableInNewTerritories: isAvailableInNewTerritories), + relationships: .init( + app: .init(data: .init(type: .apps, id: appId)), + territoryAvailabilities: .init(data: localIds.map { .init(type: .territoryAvailabilities, id: $0) }) + ) + ), + included: zip(localIds, territoryIds).map { localId, territoryId in + TerritoryAvailabilityInlineCreate( + type: .territoryAvailabilities, + id: localId, + attributes: .init(isAvailable: true), + relationships: .init(territory: .init(data: .init(type: .territories, id: territoryId))) + ) + } + ) + _ = try await client.request(APIEndpoint.v2.appAvailabilities.post(body)) + // Read it back for Apple's per-territory statuses. + guard let created = try await getAppAvailability(appId: appId) else { + throw APIError.unknown("App availability for \(appId) was created but can't be read back") + } + return created + } + private func mapTerritoryAvailability( _ sdk: AppStoreConnect_Swift_SDK.TerritoryAvailability ) -> Domain.AppTerritoryAvailability? { diff --git a/Tests/ASCCommandTests/Commands/Apps/Availability/AppAvailabilityCreateTests.swift b/Tests/ASCCommandTests/Commands/Apps/Availability/AppAvailabilityCreateTests.swift new file mode 100644 index 00000000..4a1767d6 --- /dev/null +++ b/Tests/ASCCommandTests/Commands/Apps/Availability/AppAvailabilityCreateTests.swift @@ -0,0 +1,78 @@ +import ArgumentParser +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +@Suite +struct AppAvailabilityCreateTests { + + private let created = AppAvailability(id: "avail-1", appId: "app-1", isAvailableInNewTerritories: true, territories: [ + AppTerritoryAvailability(id: "ta-1", territoryId: "USA", isAvailable: true, releaseDate: nil, + isPreOrderEnabled: false, contentStatuses: []), + ]) + + @Test func `creating availability for chosen territories shows the result`() async throws { + let repo = MockAppAvailabilityRepository() + given(repo).createAppAvailability(appId: .any, isAvailableInNewTerritories: .any, territoryIds: .any).willReturn(created) + + let cmd = try AppAvailabilityCreate.parse(["--app-id", "app-1", "--territory", "USA", "--territory", "JPN", + "--available-in-new-territories", "--pretty"]) + let output = try await cmd.execute(repo: repo, territoryRepo: MockTerritoryRepository()) + + verify(repo).createAppAvailability(appId: .value("app-1"), isAvailableInNewTerritories: .value(true), + territoryIds: .value(["USA", "JPN"])).called(1) + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "getAvailability" : "asc app-availability get --app-id app-1", + "listTerritories" : "asc territories list" + }, + "appId" : "app-1", + "id" : "avail-1", + "isAvailableInNewTerritories" : true, + "territories" : [ + { + "contentStatuses" : [ + + ], + "id" : "ta-1", + "isAvailable" : true, + "isPreOrderEnabled" : false, + "territoryId" : "USA" + } + ] + } + ] + } + """) + } + + @Test func `all territories makes the app available everywhere Apple sells`() async throws { + let repo = MockAppAvailabilityRepository() + given(repo).createAppAvailability(appId: .any, isAvailableInNewTerritories: .any, territoryIds: .any).willReturn(created) + let territories = MockTerritoryRepository() + given(territories).listTerritories().willReturn([ + Territory(id: "USA", currency: "USD"), Territory(id: "JPN", currency: "JPY"), Territory(id: "FRA", currency: "EUR"), + ]) + + let cmd = try AppAvailabilityCreate.parse(["--app-id", "app-1", "--all-territories"]) + _ = try await cmd.execute(repo: repo, territoryRepo: territories) + + verify(repo).createAppAvailability(appId: .value("app-1"), isAvailableInNewTerritories: .value(false), + territoryIds: .value(["USA", "JPN", "FRA"])).called(1) + } + + @Test func `creating availability needs either territories or all territories`() async throws { + let neither = try AppAvailabilityCreate.parse(["--app-id", "app-1"]) + await #expect(throws: ValidationError.self) { + _ = try await neither.execute(repo: MockAppAvailabilityRepository(), territoryRepo: MockTerritoryRepository()) + } + let both = try AppAvailabilityCreate.parse(["--app-id", "app-1", "--territory", "USA", "--all-territories"]) + await #expect(throws: ValidationError.self) { + _ = try await both.execute(repo: MockAppAvailabilityRepository(), territoryRepo: MockTerritoryRepository()) + } + } +} diff --git a/Tests/ASCCommandTests/Commands/Apps/Availability/AppAvailabilityGetTests.swift b/Tests/ASCCommandTests/Commands/Apps/Availability/AppAvailabilityGetTests.swift index 3a1c8336..d48f6162 100644 --- a/Tests/ASCCommandTests/Commands/Apps/Availability/AppAvailabilityGetTests.swift +++ b/Tests/ASCCommandTests/Commands/Apps/Availability/AppAvailabilityGetTests.swift @@ -72,4 +72,19 @@ struct AppAvailabilityGetTests { } """) } + + @Test func `an app with no availability set up shows no data`() async throws { + let mockRepo = MockAppAvailabilityRepository() + given(mockRepo).getAppAvailability(appId: .any).willReturn(nil) + + let output = try await AppAvailabilityGet.parse(["--app-id", "app-42", "--pretty"]).execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + + ] + } + """) + } } diff --git a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift index 4719a90d..03359312 100644 --- a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift +++ b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift @@ -1245,4 +1245,19 @@ struct RESTRoutesTests { #expect(output.contains("/api/v1/apps/app-1/prices/set")) #expect(output.contains("/api/v1/apps/app-1/price-points")) } + + // MARK: - App availability + + @Test func `app availability links to itself over REST`() async throws { + let repo = MockAppAvailabilityRepository() + given(repo).getAppAvailability(appId: .any).willReturn( + AppAvailability(id: "avail-1", appId: "app-42", isAvailableInNewTerritories: true, territories: []) + ) + + let output = try await AppAvailabilityGet.parse(["--app-id", "app-42"]) + .execute(repo: repo, affordanceMode: .rest).replacingOccurrences(of: "\\/", with: "/") + + #expect(output.contains("\"_links\"")) + #expect(output.contains("/api/v1/apps/app-42/availability")) + } } diff --git a/Tests/DomainTests/Apps/Availability/AppAvailabilityTests.swift b/Tests/DomainTests/Apps/Availability/AppAvailabilityTests.swift index 0fb9f813..ab06aed2 100644 --- a/Tests/DomainTests/Apps/Availability/AppAvailabilityTests.swift +++ b/Tests/DomainTests/Apps/Availability/AppAvailabilityTests.swift @@ -33,4 +33,21 @@ struct AppAvailabilityTests { let availability = MockRepositoryFactory.makeAppAvailability() #expect(availability.affordances["listTerritories"] == "asc territories list") } + + @Test func `app availability links to reading it over REST`() { + let availability = MockRepositoryFactory.makeAppAvailability(appId: "app-42") + #expect(availability.apiLinks["getAvailability"]?.href == "/api/v1/apps/app-42/availability") + #expect(availability.apiLinks["getAvailability"]?.method == "GET") + } + + @Test func `the availability table row shows how many territories are available`() { + let availability = AppAvailability(id: "avail-1", appId: "app-1", isAvailableInNewTerritories: true, territories: [ + AppTerritoryAvailability(id: "ta-1", territoryId: "USA", isAvailable: true, releaseDate: nil, + isPreOrderEnabled: false, contentStatuses: []), + AppTerritoryAvailability(id: "ta-2", territoryId: "CHN", isAvailable: false, releaseDate: nil, + isPreOrderEnabled: false, contentStatuses: []), + ]) + #expect(AppAvailability.tableHeaders == ["ID", "App ID", "Available in New Territories", "Territories"]) + #expect(availability.tableRow == ["avail-1", "app-1", "true", "1/2 available"]) + } } diff --git a/Tests/InfrastructureTests/Apps/Availability/SDKAppAvailabilityRepositoryTests.swift b/Tests/InfrastructureTests/Apps/Availability/SDKAppAvailabilityRepositoryTests.swift index 4ca986e0..3b58cc53 100644 --- a/Tests/InfrastructureTests/Apps/Availability/SDKAppAvailabilityRepositoryTests.swift +++ b/Tests/InfrastructureTests/Apps/Availability/SDKAppAvailabilityRepositoryTests.swift @@ -47,15 +47,15 @@ struct SDKAppAvailabilityRepositoryTests { let repo = SDKAppAvailabilityRepository(client: stub) let result = try await repo.getAppAvailability(appId: "app-99") - #expect(result.id == "avail-1") - #expect(result.appId == "app-99") - #expect(result.isAvailableInNewTerritories == true) - #expect(result.territories.count == 2) - #expect(result.territories[0].territoryId == "USA") - #expect(result.territories[0].isAvailable == true) - #expect(result.territories[1].territoryId == "CHN") - #expect(result.territories[1].isAvailable == false) - #expect(result.territories[1].contentStatuses == [.cannotSellRestrictedRating]) + #expect(result?.id == "avail-1") + #expect(result?.appId == "app-99") + #expect(result?.isAvailableInNewTerritories == true) + #expect(result?.territories.count == 2) + #expect(result?.territories[0].territoryId == "USA") + #expect(result?.territories[0].isAvailable == true) + #expect(result?.territories[1].territoryId == "CHN") + #expect(result?.territories[1].isAvailable == false) + #expect(result?.territories[1].contentStatuses == [.cannotSellRestrictedRating]) } @Test func `getAppAvailability maps pre-order fields from dedicated relationship`() async throws { @@ -90,8 +90,8 @@ struct SDKAppAvailabilityRepositoryTests { let repo = SDKAppAvailabilityRepository(client: stub) let result = try await repo.getAppAvailability(appId: "app-1") - #expect(result.territories[0].releaseDate == "2026-04-01") - #expect(result.territories[0].isPreOrderEnabled == true) + #expect(result?.territories[0].releaseDate == "2026-04-01") + #expect(result?.territories[0].isPreOrderEnabled == true) } @Test func `getAppAvailability handles empty territories`() async throws { @@ -109,7 +109,7 @@ struct SDKAppAvailabilityRepositoryTests { let repo = SDKAppAvailabilityRepository(client: stub) let result = try await repo.getAppAvailability(appId: "app-1") - #expect(result.territories.isEmpty) + #expect(result?.territories.isEmpty == true) } @Test func `getAppAvailability returns more than ten territories - regression against include truncation`() async throws { @@ -135,6 +135,48 @@ struct SDKAppAvailabilityRepositoryTests { let repo = SDKAppAvailabilityRepository(client: stub) let result = try await repo.getAppAvailability(appId: "app-many") - #expect(result.territories.count == 175) + #expect(result?.territories.count == 175) + } + + // MARK: - Not set up / create + + @Test func `an app with no availability set up reads as none`() async throws { + let stub = StubAPIClient() + stub.errorToThrow = APIProvider.Error.requestFailure(404, ErrorResponse(errors: [ + ResponseError(status: "404", code: "NOT_FOUND", title: "The specified resource does not exist", + detail: "There is no resource of type 'appAvailabilities' with id 'app-1'"), + ]), nil) + + let repo = SDKAppAvailabilityRepository(client: stub) + let result = try await repo.getAppAvailability(appId: "app-1") + + #expect(result == nil) + } + + @Test func `creating availability makes every given territory available in one request`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppAvailabilityV2Response( + data: AppAvailabilityV2(type: .appAvailabilities, id: "avail-1", attributes: .init(isAvailableInNewTerritories: true)), + links: .init(this: "") + )) + stub.willReturn(TerritoryAvailabilitiesResponse( + data: [ + TerritoryAvailability(type: .territoryAvailabilities, id: "ta-1", attributes: .init(isAvailable: true), + relationships: .init(territory: .init(data: .init(type: .territories, id: "USA")))), + ], + links: .init(this: "") + )) + + let repo = SDKAppAvailabilityRepository(client: stub) + let result = try await repo.createAppAvailability( + appId: "app-1", isAvailableInNewTerritories: true, territoryIds: ["USA", "JPN"] + ) + + let post = stub.requests.first { $0.method == "POST" } + #expect(post?.path == "/v2/appAvailabilities") + #expect(post?.body == #"{"data":{"attributes":{"availableInNewTerritories":true},"relationships":{"app":{"data":{"id":"app-1","type":"apps"}},"territoryAvailabilities":{"data":[{"id":"${ta-USA}","type":"territoryAvailabilities"},{"id":"${ta-JPN}","type":"territoryAvailabilities"}]}},"type":"appAvailabilities"},"included":[{"attributes":{"available":true},"id":"${ta-USA}","relationships":{"territory":{"data":{"id":"USA","type":"territories"}}},"type":"territoryAvailabilities"},{"attributes":{"available":true},"id":"${ta-JPN}","relationships":{"territory":{"data":{"id":"JPN","type":"territories"}}},"type":"territoryAvailabilities"}]}"#) + #expect(result.appId == "app-1") + #expect(result.isAvailableInNewTerritories) + #expect(result.territories.map(\.territoryId) == ["USA"]) } } diff --git a/docs/features/iap-subscription-availability.md b/docs/features/iap-subscription-availability.md index 421aa50e..bd99e3d8 100644 --- a/docs/features/iap-subscription-availability.md +++ b/docs/features/iap-subscription-availability.md @@ -27,6 +27,25 @@ Example output: } ``` +An app whose availability was never set up (App Store Connect shows **Set Up Availability**) returns `{"data":[]}`, and the CLI prints a hint with the `create` command on stderr. + +#### Set up app availability + +```bash +asc app-availability create --app-id --all-territories --available-in-new-territories +asc app-availability create --app-id --territory USA --territory JPN [--available-in-new-territories] +``` + +| Flag | Description | +|------|-------------| +| `--territory` | Territory to make the app available in; repeat for several | +| `--all-territories` | Every territory from `asc territories list` (exactly one of these two) | +| `--available-in-new-territories` | Also make it available in territories Apple adds later | + +One `POST /v2/appAvailabilities` with each territory as an inline `territoryAvailabilities` entry (`available: true`), then the availability is read back. Before release every territory reports `CANNOT_SELL` + `AVAILABLE_FOR_SALE_UNRELEASED_APP` — expected until the app is live. + +REST: `GET /api/v1/apps/{appId}/availability`, `POST /api/v1/apps/{appId}/availability` with body `{"territory": ["USA"]}` or `{"all-territories": true}`, plus optional `"available-in-new-territories": true`. + **ContentStatus values** include: `AVAILABLE`, `MISSING_RATING`, `CANNOT_SELL_RESTRICTED_RATING`, `CANNOT_SELL_GAMBLING`, `BRAZIL_REQUIRED_TAX_ID`, `ICP_NUMBER_MISSING`, and 30+ more reasons explaining why a territory is blocked. ### Discover Territories