diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate index 3ae32fa..be7d372 100644 Binary files a/.swiftpm/xcode/package.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Sources/PurchaseKit/Manager/RKPurchasesManager.swift b/Sources/PurchaseKit/Manager/RKPurchasesManager.swift index 154b719..fccf1fd 100644 --- a/Sources/PurchaseKit/Manager/RKPurchasesManager.swift +++ b/Sources/PurchaseKit/Manager/RKPurchasesManager.swift @@ -8,12 +8,17 @@ import Foundation import StoreKit -/// ``PurchasesManager`` – entry point to `RKPurchaseKit` -/// See for an overview and API map. +/// ``PurchasesManager`` – entry point to `RKPurchaseKit`. +/// +/// The manager is an `actor` and is therefore safe to use from concurrent contexts. +/// It holds a product cache, listens for `Transaction.updates`, and exposes a simple +/// API for fetching products, purchasing, restoring, and querying current entitlements. +/// See for the overview. public actor PurchasesManager: PurchasesProtocol { // MARK: Properties + /// Global singleton configured via ``configure(identifiers:)``. public nonisolated static var shared: PurchasesManager { guard let instance else { fatalError("❗️ PurchasesActor.configure(identifiers:) must be called before first use.") @@ -21,6 +26,9 @@ public actor PurchasesManager: PurchasesProtocol { return instance } + /// Async stream of purchase events emitted when a product becomes entitled. + /// + /// You can `for await` this stream to reactively update UI or unlock features. public nonisolated let purchasedProducts: AsyncStream private let identifiers: [String] private var productsCache: [String: StoreProduct] = [:] @@ -43,10 +51,11 @@ public actor PurchasesManager: PurchasesProtocol { } // MARK: Public methods - /// Creates the singleton and starts the `StoreKit` listener. - /// - SeeAlso: ``PurchasesManager/shared`` - /// - Parameters: - /// - identifiers: Product IDs registered in App Store Connect. + + /// Creates the singleton and starts the StoreKit transaction listener. + /// + /// - Parameter identifiers: Product IDs registered in App Store Connect. + /// - Returns: The configured singleton instance. @discardableResult public nonisolated static func configure(identifiers: [String]) -> PurchasesManager { precondition(instance == nil, "PurchasesActor.configure(_:) has already been called. Double configuration is not allowed.") @@ -58,17 +67,15 @@ public actor PurchasesManager: PurchasesProtocol { return instance } - /// Fetches products from `StoreKit` (optionally returns cache first). - /// - See - /// - Parameters: - /// - includingCache: If `true`, cached products are returned immediately. - /// - Returns: Array of ``StoreProduct``. - /// - Throws: ``PurchasesError`` - /// - Note: Uses `Product.products(for:)` under the hood. + /// Fetches products from StoreKit (optionally returns cache first). + /// + /// If cache is used, entitlements are refreshed asynchronously so the + /// `isPurchased` flag remains accurate. public func requestProducts(includingCache: Bool = true) async throws -> [StoreProduct] { if includingCache { let cachedProducts = productsCache.values.map { $0 } if !cachedProducts.isEmpty { + Task { await self.refreshEntitlements() } return cachedProducts } } @@ -80,12 +87,7 @@ public actor PurchasesManager: PurchasesProtocol { return productsCache.values.map { $0 } } - /// Performs a purchase flow for the given product ID. - /// - SeeAlso: - /// - Throws: ``PurchasesError/purchaseCancelled``, - /// ``PurchasesError/purchasePending``, - /// ``PurchasesError/verificationFailed`` - /// - Returns: Verified ``StoreProduct`` just bought. + /// Performs a purchase flow for the given product identifier. public func purchase(productID: String) async throws -> StoreProduct { guard let product = try await Product.products(for: [productID]).first else { throw PurchasesError.invalidProductID(productID) @@ -107,11 +109,85 @@ public actor PurchasesManager: PurchasesProtocol { throw PurchasesError.unknown(PurchasesError.unknown(NSError(domain: "unknown", code: -1))) } } - /// Syncs with the App Store to restore previous purchases. - /// - Throws: ``PurchasesError`` - /// - See + /// Syncs with the App Store and refreshes current entitlements. public func restore() async throws { try await AppStore.sync() + await refreshEntitlements() + } + /// Returns `true` if the user currently has an active entitlement for `productID`. + /// + /// Uses `Transaction.currentEntitlements` under the hood and falls back to the cache + /// if available for fast checks. + public func hasEntitlement(for productID: String) async -> Bool { + if let cached = productsCache[productID] { + return cached.isPurchased + } + for await result in Transaction.currentEntitlements { + if let transaction = try? checkVerified(result), transaction.productID == productID { + return true + } + } + return false + } + /// Returns the set of product identifiers for which the user has an active entitlement. + public func entitlementProductIDs() async -> Set { + var ids: Set = [] + for await result in Transaction.currentEntitlements { + if let transaction = try? checkVerified(result) { + ids.insert(transaction.productID) + } + } + return ids + } + /// Returns all active **auto-renewable** subscriptions mapped to ``StoreProduct``. + /// + /// If a product is not yet cached, it will be fetched from StoreKit and cached. + public func activeSubscriptions() async -> [StoreProduct] { + if productsCache.isEmpty { + _ = try? await requestProducts(includingCache: true) + } + var result: [StoreProduct] = [] + for await resultTransaction in Transaction.currentEntitlements { + guard let transaction = try? checkVerified(resultTransaction) else { continue } + + if let product = productsCache[transaction.productID], product.type == .autoRenewable { + result.append(product) + } else if productsCache[transaction.productID] == nil { + if let fetched = try? await Product.products(for: [transaction.productID]).first { + cache(fetched, purchased: true) + if let storeProduct = productsCache[transaction.productID], storeProduct.type == .autoRenewable { + result.append(storeProduct) + } + } + } + } + return result + } + /// Returns the best active subscription for the given subscription group. + /// + /// If multiple entitlements from the same group are present, the one with the latest + /// expiration date is returned. + /// - Parameter groupID: Subscription group identifier as configured in App Store Connect. + public func activeSubscription(inGroup groupID: String) async -> StoreProduct? { + var best: (product: StoreProduct, expires: Date?)? + if productsCache.isEmpty { + _ = try? await requestProducts(includingCache: true) + } + for await resultTransaction in Transaction.currentEntitlements { + guard let transaction = try? checkVerified(resultTransaction) else { continue } + // Проверяем, что это подписка в нужной группе + if let product = try? await storeProduct(for: transaction.productID), + product.type == .autoRenewable, + product.subscriptionGroupID == groupID + { + // У auto-renewable подписок у транзакции обычно есть expirationDate + let expiration = transaction.expirationDate + if best == nil || compare(expiration, isLaterThan: best?.expires) { + best = (product, expiration) + } + } + } + return best?.product } // MARK: Private methods @@ -129,14 +205,14 @@ public actor PurchasesManager: PurchasesProtocol { private func map(_ product: Product, _ isPurchased: Bool) -> StoreProduct { StoreProduct(product: product, isPurchased: isPurchased) } - + /// Verifies StoreKit's `VerificationResult` and returns the signed value. private func checkVerified(_ result: VerificationResult) throws -> T { switch result { case .verified(let signed): signed case .unverified: throw PurchasesError.verificationFailed } } - + /// Marks the given product as purchased and emits a ``PurchasedProductEvent``. private func markPurchased(productID: String) async throws { guard let cached = productsCache[productID] else { return } @@ -146,19 +222,9 @@ public actor PurchasesManager: PurchasesProtocol { } private func updateCustomerProductStatus() async { - for await result in Transaction.currentEntitlements { - guard - let transaction = try? checkVerified(result), - let products = try? await Product.products(for: [transaction.productID]), - let product = products.first - else { - continue - } - - try? await markPurchased(productID: product.id) - } + await refreshEntitlements() } - + /// Listens to live transaction updates and finishes them. private func listenForTransactions() async { for await result in Transaction.updates { guard let transaction = try? checkVerified(result) else { continue } @@ -167,4 +233,48 @@ public actor PurchasesManager: PurchasesProtocol { await transaction.finish() } } + /// Rebuilds the entitlement state: + /// 1) Clears `isPurchased` on all cached products. + /// 2) Sets it to `true` for anything present in `Transaction.currentEntitlements`. + private func refreshEntitlements() async { + // 1) Clear all flags + if !productsCache.isEmpty { + for (index, storeProduct) in productsCache where storeProduct.isPurchased { + productsCache[index] = storeProduct.setPurchasingFlag(false) + } + } + // 2) Apply current entitlements + for await result in Transaction.currentEntitlements { + guard let transaction = try? checkVerified(result) else { continue } + + if productsCache[transaction.productID] == nil { + if let fetched = try? await Product.products(for: [transaction.productID]).first { + cache(fetched, purchased: true) + continue + } + } + try? await markPurchased(productID: transaction.productID) + } + } + /// Ensures a ``StoreProduct`` for `productID`, fetching it if needed. + private func storeProduct(for productID: String) async throws -> StoreProduct { + if let cached = productsCache[productID] { + return cached + } + guard let fetched = try await Product.products(for: [productID]).first else { + throw PurchasesError.invalidProductID(productID) + } + + cache(fetched) + + return productsCache[productID]! + } + + private func compare(_ lhs: Date?, isLaterThan rhs: Date?) -> Bool { + switch (lhs, rhs) { + case let (l?, r?): l > r + case (.some, .none): true + default: false + } + } } diff --git a/Sources/PurchaseKit/Models/RKStoreProduct.swift b/Sources/PurchaseKit/Models/RKStoreProduct.swift index 40dc044..550f665 100644 --- a/Sources/PurchaseKit/Models/RKStoreProduct.swift +++ b/Sources/PurchaseKit/Models/RKStoreProduct.swift @@ -13,19 +13,34 @@ import StoreKit public struct StoreProduct: Sendable { // MARK: Properties - + /// The raw StoreKit product object. public let product: Product + /// The product identifier from App Store Connect. public let productID: String + /// High-level product type (mapped from `Product.ProductType`). public let type: ProductType + /// Localized display name as presented by App Store. public let displayName: String + /// Localized description as presented by App Store. public let description: String + /// Numeric price (ISO currency via `product.priceFormatStyle.currencyCode`). public let price: Decimal + /// Localized formatted price (e.g. `"$4.99"`). public let displayPrice: String + /// Whether family sharing is allowed. public let isFamilyShareable: Bool + /// Convenience flag reflecting whether the user currently holds an entitlement + /// for this product (derived from `Transaction.currentEntitlements`). public let isPurchased: Bool + /// Subscription group identifier for auto-renewable subscriptions. + /// + /// `nil` for non-subscription products and non-grouped items. + public let subscriptionGroupID: String? // MARK: Initial method + /// Designated initializer. You don't create `StoreProduct` manually in apps — + /// it is produced by the kit from `StoreKit.Product`. init(product: Product, isPurchased: Bool) { self.product = product productID = product.id @@ -36,12 +51,15 @@ public struct StoreProduct: Sendable { displayPrice = product.displayPrice isFamilyShareable = product.isFamilyShareable self.isPurchased = isPurchased + subscriptionGroupID = product.subscription?.subscriptionGroupID } // MARK: Internal methods - /// Convenience copy-initializer that toggles the `isPurchased` flag. - /// - Returns: New ``StoreProduct`` instance. + /// Returns a copy with an updated `isPurchased` flag. + /// + /// - Parameter isPurchased: New entitlement state. + /// - Returns: A new ``StoreProduct`` instance. func setPurchasingFlag(_ isPurchased: Bool) -> StoreProduct { StoreProduct(product: product, isPurchased: isPurchased) } diff --git a/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift b/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift index 565f3e5..82e6397 100644 --- a/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift +++ b/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift @@ -10,9 +10,47 @@ import Foundation /// Protocol abstraction to allow mocking in tests. /// Full spec: public protocol PurchasesProtocol: Sendable { + /// Fetches products from StoreKit (optionally returns cached values first). + /// + /// Internally uses `Product.products(for:)` for the identifiers passed to + /// ``PurchasesManager/configure(identifiers:)``. + /// + /// - Parameter includingCache: If `true`, returns cached products immediately + /// and refreshes entitlements in the background. + /// - Returns: Array of ``StoreProduct``. + /// - Throws: ``PurchasesError`` if StoreKit lookup fails. func requestProducts(includingCache: Bool) async throws -> [StoreProduct] + /// Starts a purchase flow for the given product. + /// + /// - Parameter productID: A product identifier registered in App Store Connect. + /// - Returns: A verified ``StoreProduct`` that has just been purchased. + /// - Throws: ``PurchasesError/purchaseCancelled``, ``PurchasesError/purchasePending``, + /// ``PurchasesError/verificationFailed``, or ``PurchasesError/invalidProductID(_:)``. func purchase(productID: String) async throws -> StoreProduct + /// Synchronizes with the App Store and re-evaluates the current entitlements. + /// + /// You typically call this from a "Restore Purchases" button. + /// + /// - Throws: ``PurchasesError`` on sync failure. func restore() async throws + /// Returns `true` if the user currently has an active entitlement for `productID`. + /// + /// Uses `Transaction.currentEntitlements` under the hood. + /// - Parameter productID: Product identifier to check. + func hasEntitlement(for productID: String) async -> Bool + /// Returns the set of all product identifiers for which the user has an active entitlement. + /// + /// The result reflects **current** rights only (including grace period). + func entitlementProductIDs() async -> Set + /// Returns all active **auto-renewable** subscriptions mapped to your ``StoreProduct`` model. + /// + /// If a product is not cached yet, it will be fetched from StoreKit on demand. + func activeSubscriptions() async -> [StoreProduct] + /// Returns the active subscription within a specific subscription group, if any. + /// + /// If multiple are present, the subscription with the latest expiration date is returned. + /// - Parameter groupID: The subscription group identifier from App Store Connect. + func activeSubscription(inGroup groupID: String) async -> StoreProduct? } /// Default wrapper that keeps source compatibility. diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/GettingStarted.md b/Sources/PurchaseKit/RKPurchaseKit.docc/GettingStarted.md index ceacd3e..ab5384d 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/GettingStarted.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/GettingStarted.md @@ -14,7 +14,9 @@ import RKPurchaseKit PurchasesManager.configure(identifiers: [ "com.myapp.pro", - "com.myapp.consumable.coin" + "com.myapp.consumable.coin", + "com.myapp.sub.premium.monthly", + "com.myapp.sub.premium.yearly" ]) ``` @@ -76,6 +78,63 @@ try await PurchasesManager.shared.restore() Provide an explicit “Restore Purchases” button if required by App Store Review. +--- +## 6 Gate features by entitlement + +Use current entitlements to unlock features (no receipt parsing needed): + +```swift +let hasPro = await PurchasesManager.shared.hasEntitlement(for: "com.myapp.pro") + +if hasPro { + // unlock premium UI +} +``` +Or quickly check all entitled product identifiers: +```swift +let ids = await PurchasesManager.shared.entitlementProductIDs() +``` +See: ``PurchasesManager/hasEntitlement(for:)``, ``PurchasesManager/entitlementProductIDs()`` +--- +## 7 Work with subscriptions (groups) + +List all active auto-renewable subscriptions: +```swift +let active = await PurchasesManager.shared.activeSubscriptions() +``` +Select the best (latest-expiring) subscription in a group: +```swift +if let subscription = await PurchasesManager.shared.activeSubscription(inGroup: "com.myapp.subscriptions.premium") { + print("Active plan:", subscription.displayName) +} +``` + +Where to get groupID? +It’s available on the product as +StoreProduct/subscriptionGroupID (derived from Product.subscription.subscriptionGroupID) and configured in App Store Connect. + +See: ``PurchasesManager/activeSubscriptions()``, ``PurchasesManager/activeSubscription(inGroup:)``, +``StoreProduct/subscriptionGroupID``. +--- +## 8 Testing & mocking + +Depend on PurchasesProtocol in your app code and inject a mock in tests: +```swift +struct PurchasesMock: PurchasesProtocol { + func requestProducts(includingCache: Bool) async throws -> [StoreProduct] { [] } + func purchase(productID: String) async throws -> StoreProduct { throw PurchasesError.purchaseCancelled } + func restore() async throws { } + func hasEntitlement(for productID: String) async -> Bool { productID == "com.myapp.pro" } + func entitlementProductIDs() async -> Set { ["com.myapp.pro"] } + func activeSubscriptions() async -> [StoreProduct] { [] } + func activeSubscription(inGroup groupID: String) async -> StoreProduct? { nil } +} +``` +--- +## 9 Concurrency notes + +PurchasesManager is an actor, so its API is thread-safe by design. +Update UI on the main actor when reacting to events. --- ## Next steps diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/RKPurchaseKit.md b/Sources/PurchaseKit/RKPurchaseKit.docc/RKPurchaseKit.md index 133d325..4701092 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/RKPurchaseKit.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/RKPurchaseKit.md @@ -16,6 +16,17 @@ ## Features +- Actor-based, Swift Concurrency–first API +- Product caching with instant repeat calls +- Purchase & restore flows with typed errors +- Live transaction listening via `AsyncStream` +- **Entitlement helpers**: + - ``PurchasesManager/hasEntitlement(for:)`` + - ``PurchasesManager/entitlementProductIDs()`` + - ``PurchasesManager/activeSubscriptions()`` + - ``PurchasesManager/activeSubscription(inGroup:)`` +- Simple value model: ``StoreProduct`` (with ``StoreProduct/subscriptionGroupID``) + @Links(visualStyle: detailedGrid) { - - @@ -25,3 +36,14 @@ - - } + +## Requirements + +- Swift 6, StoreKit 2 +- iOS 15.0 / macOS 12.0 / tvOS 15.0 / watchOS 8.0 / visionOS 1.0+ + +## Notes + +- Entitlement state is derived from `Transaction.currentEntitlements` and reflected in ``StoreProduct/isPurchased``. +- The manager refreshes entitlements after fetching products and after ``PurchasesManager/restore()``. +- When you update UI from callbacks or streams, hop to `MainActor`. diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesManager.md b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesManager.md index e5452e1..543b8dc 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesManager.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesManager.md @@ -2,16 +2,44 @@ The central `actor` that drives all StoreKit 2 operations. +It handles product fetching, purchases, restoration, entitlement evaluation, and continuous transaction updates. Being an `actor`, it is safe to use from concurrent contexts (Swift Concurrency). + ## Topics ### Configuration -* ``PurchasesManager/configure(identifiers:)`` -* ``PurchasesManager/shared`` +- ``configure(identifiers:)`` +- ``shared`` ### Operations -* ``PurchasesManager/requestProducts(includingCache:)`` -* ``PurchasesManager/purchase(productID:)`` -* ``PurchasesManager/restore()`` +- ``requestProducts(includingCache:)`` +- ``purchase(productID:)`` +- ``restore()`` + +### Entitlements & Subscriptions +- ``hasEntitlement(for:)`` +- ``entitlementProductIDs()`` +- ``activeSubscriptions()`` +- ``activeSubscription(inGroup:)`` ### Events -* ``PurchasesManager/purchasedProducts`` +- ``purchasedProducts`` + +## Usage + +```swift +// Configure once at app launch +let purchases = PurchasesManager.configure(identifiers: [ + "com.myapp.sub.premium.monthly", + "com.myapp.sub.premium.yearly", + "com.myapp.tip.small" +]) + +// Check entitlement +let hasPro = await purchases.hasEntitlement(for: "com.myapp.sub.premium.yearly") + +// List active subscriptions +let active = await purchases.activeSubscriptions() + +// Pick the active subscription in a group +let best = await purchases.activeSubscription(inGroup: "com.myapp.subscriptions.premium") +``` diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesProtocol.md b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesProtocol.md index 37e1101..7a8f83c 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesProtocol.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesProtocol.md @@ -1,16 +1,24 @@ # ``PurchasesProtocol`` -An abstraction that lets you swap `PurchasesManager` for a mock implementation in unit-tests or previews. +An abstraction that lets you swap ``PurchasesManager`` for a mock implementation in unit tests or previews. + +The protocol mirrors the public surface of the manager and adds high-level helpers for entitlements and subscriptions. ## Topics ### Core Methods -* ``PurchasesProtocol/requestProducts(includingCache:)`` -* ``PurchasesProtocol/purchase(productID:)`` -* ``PurchasesProtocol/restore()`` +- ``requestProducts(includingCache:)`` +- ``purchase(productID:)`` +- ``restore()`` + +### Entitlements & Subscriptions +- ``hasEntitlement(for:)`` +- ``entitlementProductIDs()`` +- ``activeSubscriptions()`` +- ``activeSubscription(inGroup:)`` ### Default Implementations -`PurchasesProtocol` ships with a default parameter for `includingCache` so most callers can write just: +`PurchasesProtocol` ships with a default parameter for `includingCache` so most callers can write: ```swift let products = try await manager.requestProducts() diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md index a12b8ea..42c17b6 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md @@ -1,13 +1,17 @@ # ``StoreProduct`` -Value-type wrapper around `StoreKit.Product`. +Value-type wrapper around `StoreKit.Product`, enriched with a few convenience fields for UI and entitlement state. -### Properties -* ``StoreProduct/productID`` -* ``StoreProduct/type`` -* ``StoreProduct/displayName`` -* ``StoreProduct/price`` -* ``StoreProduct/displayPrice`` -* ``StoreProduct/isPurchased`` +## Properties + +- ``StoreProduct/productID`` +- ``StoreProduct/type`` +- ``StoreProduct/displayName`` +- ``StoreProduct/price`` +- ``StoreProduct/displayPrice`` +- ``StoreProduct/isPurchased`` +- ``StoreProduct/subscriptionGroupID`` Use ``StoreProduct/setPurchasingFlag(_:)`` to create a copy with an updated purchase state. + +> Tip: For auto-renewable subscriptions, `subscriptionGroupID` helps you select the “best” active subscription within a group (see ``PurchasesManager/activeSubscription(inGroup:)``).