From 1d53d100672af64103137ffe9e263d971a4a029e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:27:38 +0900 Subject: [PATCH] feat(catalog): expose StoreKit product lookup inputs --- .../Sources/Consumer/Consumer.swift | 21 +++++++ .../SubscriptionAccessTests.swift | 19 +++++++ README.md | 20 ++++++- .../DefiningSubscriptionAccess.md | 50 ++++++++++++++++- .../AutoRenewableSubscriptionCatalog.swift | 43 ++++++++++---- ...utoRenewableSubscriptionCatalogTests.swift | 56 +++++++++++++++++++ 6 files changed, 197 insertions(+), 12 deletions(-) diff --git a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift index ca1cdcc..d6c5681 100644 --- a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift +++ b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift @@ -1,4 +1,5 @@ import StoreTransactionKit +import StoreKit public enum SubscriptionEntitlement: Hashable, Sendable { case tier1 @@ -27,6 +28,26 @@ public enum Plans: AutoRenewableSubscriptionGroup { public let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) +public let subscriptionProductIDs = subscriptionCatalog.productIDs + +public func loadDeclaredSubscriptionProducts() async throws -> [Product] { + try await Product.products(for: subscriptionCatalog.productIDs) +} + +public func loadSubscriptionStatuses() async throws + -> [Product.SubscriptionInfo.Status] +{ + try await Product.SubscriptionInfo.status( + for: subscriptionCatalog.subscriptionGroupID.rawValue + ) +} + +public func entitlement( + for productID: String +) -> SubscriptionEntitlement? { + subscriptionCatalog.entitlement(for: productID) +} + public let legacySubscriptionProductID = "external-consumer.subscription.legacy" diff --git a/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift index a5f4248..819264f 100644 --- a/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift +++ b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift @@ -4,6 +4,25 @@ import StoreTransactionKitTesting import StoreKit import Testing +@Test +func catalogExposesStoreKitProductLookupInputs() { + #expect(subscriptionCatalog.subscriptionGroupID == Plans.id) + #expect( + subscriptionProductIDs + == [ + Plans.ProductID.tier1_Monthly.rawValue, + Plans.ProductID.tier1_Yearly.rawValue, + Plans.ProductID.tier2_Monthly.rawValue, + Plans.ProductID.tier2_Yearly.rawValue, + ] + ) + #expect( + entitlement(for: Plans.ProductID.tier1_Yearly.rawValue) + == .tier1 + ) + #expect(entitlement(for: "external-consumer.unknown") == nil) +} + @Test @MainActor func tier1PurchaseAndExpirationUpdateViewModel() async throws { diff --git a/README.md b/README.md index 529329d..eaea63f 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ struct ContentView: View { } .sheet(isPresented: $isShowingPaywall) { SubscriptionStoreView( - productIDs: Plans.subscriptions.map(\.id.rawValue) + productIDs: subscriptionCatalog.productIDs ) } } @@ -166,6 +166,24 @@ configuration when running local StoreKit tests. Keep normal app content usable while the entitlement set is unavailable. Gate only features that require an active purchase. +## Product information + +Load the catalog's declared products when building custom subscription UI: + +```swift +import StoreKit + +let products = try await Product.products( + for: subscriptionCatalog.productIDs +) +``` + +`Product` supplies localized presentation, and `Product.SubscriptionInfo` +supplies subscription level and period. `StoreTransactionSnapshot` remains the +verified transaction projection. See +[Defining subscription access](Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md) +for the complete mapping example. + ## Override entitlements An app-defined debug, preview, or distribution environment can bypass StoreKit diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md index b644ec6..e8bdfb7 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md @@ -66,7 +66,7 @@ Pass the same declared Product IDs to StoreKit's subscription view: import StoreKit SubscriptionStoreView( - productIDs: Plans.subscriptions.map(\.id.rawValue) + productIDs: subscriptionCatalog.productIDs ) ``` @@ -77,6 +77,54 @@ typed access by default, and doesn't by itself make entitlement readiness fail. See to choose another policy with ``UnrecognizedSubscriptionDelegate``. +## Load product information + +The catalog exposes declared Product IDs in declaration order. Load their +current StoreKit metadata for custom merchandising: + +```swift +import StoreKit + +let loadedProducts = try await Product.products( + for: subscriptionCatalog.productIDs +) +let productsByID = Dictionary( + uniqueKeysWithValues: loadedProducts.map { ($0.id, $0) } +) + +for productID in subscriptionCatalog.productIDs { + guard + let product = productsByID[productID], + let entitlement = subscriptionCatalog.entitlement(for: productID), + let subscription = product.subscription + else { + continue + } + + let displayName = product.displayName + let displayPrice = product.displayPrice + let groupLevel = subscription.groupLevel + let period = subscription.subscriptionPeriod +} +``` + +``AutoRenewableSubscriptionCatalog/entitlement(for:)`` joins a StoreKit +`Product` to the app entitlement declared by this binary. +`Product.SubscriptionInfo` supplies the current group level, period, group +metadata, and offers. It is product metadata rather than part of the verified +``StoreTransactionSnapshot``. + +Query current renewal status separately with the same catalog identity: + +```swift +let statuses = try await Product.SubscriptionInfo.status( + for: subscriptionCatalog.subscriptionGroupID.rawValue +) +``` + +Use these statuses for renewal and billing presentation. Keep +``TransactionStore/activeEntitlements`` as the access source of truth. + ## Read access without blocking the UI ``TransactionStore/isEntitled(to:)`` performs exact set membership and returns diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift index 80477fc..8faef98 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift @@ -7,10 +7,20 @@ import StoreKit /// and group metadata before publication. public struct AutoRenewableSubscriptionCatalog: Sendable where Entitlement: Hashable & Sendable { - package let subscriptionGroupID: SubscriptionGroupID + private struct Entry: Sendable { + let entitlement: Entitlement + } private let declaringGroupTypeID: ObjectIdentifier - private let entitlementsByProductID: [Product.ID: Entitlement] + private let entriesByProductID: [Product.ID: Entry] + + /// The subscription group identifier configured in App Store Connect. + public let subscriptionGroupID: SubscriptionGroupID + + /// The declared product identifiers in declaration order. + /// + /// Use this collection with StoreKit product-loading and merchandising APIs. + public let productIDs: [Product.ID] /// Creates and validates a catalog from one auto-renewable subscription group. /// @@ -26,8 +36,10 @@ where Entitlement: Hashable & Sendable { "An auto-renewable subscription group must declare at least one product." ) - var entitlementsByProductID: [Product.ID: Entitlement] = [:] - entitlementsByProductID.reserveCapacity(subscriptions.count) + var entriesByProductID: [Product.ID: Entry] = [:] + entriesByProductID.reserveCapacity(subscriptions.count) + var productIDs: [Product.ID] = [] + productIDs.reserveCapacity(subscriptions.count) for subscription in subscriptions { let productID = subscription.id.rawValue @@ -37,16 +49,27 @@ where Entitlement: Hashable & Sendable { "A subscription product identifier must not be empty." ) precondition( - entitlementsByProductID[productID] == nil, + entriesByProductID[productID] == nil, "A subscription product identifier must not be declared more than once: \(productID)" ) - entitlementsByProductID[productID] = subscription.entitlement + entriesByProductID[productID] = Entry( + entitlement: subscription.entitlement + ) + productIDs.append(productID) } subscriptionGroupID = Group.id + self.productIDs = productIDs declaringGroupTypeID = ObjectIdentifier(groupType) - self.entitlementsByProductID = entitlementsByProductID + self.entriesByProductID = entriesByProductID + } + + /// Returns the app entitlement declared for a product identifier. + /// + /// The result is `nil` when the identifier isn't declared by this catalog. + public func entitlement(for productID: Product.ID) -> Entitlement? { + entriesByProductID[productID]?.entitlement } package func classification( @@ -62,13 +85,13 @@ where Entitlement: Hashable & Sendable { } package func contains(productID: Product.ID) -> Bool { - entitlementsByProductID[productID] != nil + entriesByProductID[productID] != nil } private func validatedTransaction( _ transaction: StoreTransactionSnapshot ) throws(AutoRenewableSubscriptionCatalogError) -> ValidatedTransaction { - if let entitlement = entitlementsByProductID[transaction.productID] { + if let entry = entriesByProductID[transaction.productID] { guard transaction.productType == .autoRenewable else { throw AutoRenewableSubscriptionCatalogError.productTypeMismatch( productID: transaction.productID, @@ -84,7 +107,7 @@ where Entitlement: Hashable & Sendable { ) } - return .declared(entitlement) + return .declared(entry.entitlement) } guard transaction.subscriptionGroupID == subscriptionGroupID.rawValue else { diff --git a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift index 68fb3f8..cec7ec9 100644 --- a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift +++ b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift @@ -16,10 +16,24 @@ struct AutoRenewableSubscriptionCatalogTests { #expect(countingSubscriptionsAccessCount.withLock { $0 } == 1) #expect(catalog.subscriptionGroupID == CountingPlans.id) + #expect( + catalog.productIDs + == [CountingPlans.ProductID.monthly.rawValue] + ) #expect(catalog.isDeclared(by: CountingPlans.self)) #expect(!catalog.isDeclared(by: OtherPlans.self)) #expect(catalog.contains(productID: CountingPlans.ProductID.monthly.rawValue)) #expect(!catalog.contains(productID: "com.example.subscription.undeclared")) + #expect( + catalog.entitlement( + for: CountingPlans.ProductID.monthly.rawValue + ) == .tier1 + ) + #expect( + catalog.entitlement( + for: "com.example.subscription.undeclared" + ) == nil + ) let transaction = subscriptionSnapshot( id: 0, @@ -36,6 +50,15 @@ struct AutoRenewableSubscriptionCatalogTests { @Test("monthly and yearly products classify with their declared entitlements") func classifiesDeclaredEntitlements() throws { let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + #expect( + catalog.productIDs + == [ + Plans.ProductID.tier1_Monthly.rawValue, + Plans.ProductID.tier1_Yearly.rawValue, + Plans.ProductID.tier2_Monthly.rawValue, + Plans.ProductID.tier2_Yearly.rawValue, + ] + ) let classifications = try [ Plans.ProductID.tier1_Monthly, .tier1_Yearly, @@ -60,6 +83,25 @@ struct AutoRenewableSubscriptionCatalogTests { ) } + @Test("optional entitlement values remain declared") + func optionalEntitlementRemainsDeclared() throws { + let catalog = AutoRenewableSubscriptionCatalog(OptionalPlans.self) + let productID = OptionalPlans.ProductID.monthly.rawValue + + #expect(catalog.productIDs == [productID]) + #expect(catalog.entitlement(for: productID) == .some(nil)) + #expect(catalog.contains(productID: productID)) + #expect( + try catalog.classification( + of: subscriptionSnapshot( + id: 4, + productID: productID, + subscriptionGroupID: OptionalPlans.id.rawValue + ) + ) == .declared(nil) + ) + } + @Test("an upgraded declared product remains declared") func upgradedDeclaredProductRemainsDeclared() throws { let catalog = AutoRenewableSubscriptionCatalog(Plans.self) @@ -294,6 +336,20 @@ private enum OtherPlans: } } +private enum OptionalPlans: + AutoRenewableSubscriptionGroup +{ + static let id = SubscriptionGroupID(rawValue: "optional-group") + + enum ProductID: String, Hashable, Sendable { + case monthly = "com.example.optional.monthly" + } + + static var subscriptions: StoreSubscriptions { + StoreSubscription(.monthly, entitlement: nil) + } +} + private let countingSubscriptionsAccessCount = Mutex(0) private enum CountingPlans: