Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
182 changes: 146 additions & 36 deletions Sources/PurchaseKit/Manager/RKPurchasesManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,27 @@
import Foundation
import StoreKit

/// ``PurchasesManager`` – entry point to `RKPurchaseKit`
/// See <doc:PurchasesManager> 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 <doc:PurchasesManager> 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.")
}

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<PurchasedProductEvent>
private let identifiers: [String]
private var productsCache: [String: StoreProduct] = [:]
Expand All @@ -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.")
Expand All @@ -58,17 +67,15 @@ public actor PurchasesManager: PurchasesProtocol {

return instance
}
/// Fetches products from `StoreKit` (optionally returns cache first).
/// - See <doc:GettingStarted#fetch-products>
/// - 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
}
}
Expand All @@ -80,12 +87,7 @@ public actor PurchasesManager: PurchasesProtocol {

return productsCache.values.map { $0 }
}
/// Performs a purchase flow for the given product ID.
/// - SeeAlso: <doc:GettingStarted#purchase>
/// - 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)
Expand All @@ -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 <doc:PurchasesManager/restore()>
/// 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<String> {
var ids: Set<String> = []
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
Expand All @@ -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<T>(_ result: VerificationResult<T>) 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 }

Expand All @@ -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 }
Expand All @@ -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
}
}
}
24 changes: 21 additions & 3 deletions Sources/PurchaseKit/Models/RKStoreProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
38 changes: 38 additions & 0 deletions Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,47 @@ import Foundation
/// Protocol abstraction to allow mocking in tests.
/// Full spec: <doc:PurchasesProtocol>
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<String>
/// 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.
Expand Down
Loading